diff --git a/client/modules/IDE/components/Editor/codemirror.js b/client/modules/IDE/components/Editor/codemirror.js new file mode 100644 index 0000000000..08e787724e --- /dev/null +++ b/client/modules/IDE/components/Editor/codemirror.js @@ -0,0 +1,282 @@ +import { useRef, useEffect } from 'react'; +import CodeMirror from 'codemirror'; +import 'codemirror/mode/css/css'; +import 'codemirror/mode/clike/clike'; +import 'codemirror/addon/selection/active-line'; +import 'codemirror/addon/lint/lint'; +import 'codemirror/addon/lint/javascript-lint'; +import 'codemirror/addon/lint/css-lint'; +import 'codemirror/addon/lint/html-lint'; +import 'codemirror/addon/fold/brace-fold'; +import 'codemirror/addon/fold/comment-fold'; +import 'codemirror/addon/fold/foldcode'; +import 'codemirror/addon/fold/foldgutter'; +import 'codemirror/addon/fold/indent-fold'; +import 'codemirror/addon/fold/xml-fold'; +import 'codemirror/addon/comment/comment'; +import 'codemirror/keymap/sublime'; +import 'codemirror/addon/search/searchcursor'; +import 'codemirror/addon/search/matchesonscrollbar'; +import 'codemirror/addon/search/match-highlighter'; +import 'codemirror/addon/search/jump-to-line'; +import 'codemirror/addon/edit/matchbrackets'; +import 'codemirror/addon/edit/closebrackets'; +import 'codemirror/addon/selection/mark-selection'; +import 'codemirror-colorpicker'; + +import { debounce } from 'lodash'; +import emmet from '@emmetio/codemirror-plugin'; + +import { useEffectWithComparison } from '../../hooks/custom-hooks'; +import { metaKey } from '../../../../utils/metaKey'; +import { showHint } from './hinter'; +import tidyCode from './tidier'; +import getFileMode from './utils'; + +const INDENTATION_AMOUNT = 2; + +emmet(CodeMirror); + +/** + * This is a custom React hook that manages CodeMirror state. + * TODO(Connie Ye): Revisit the linting on file switch. + */ +export default function useCodeMirror({ + theme, + lineNumbers, + linewrap, + autocloseBracketsQuotes, + setUnsavedChanges, + setCurrentLine, + hideRuntimeErrorWarning, + updateFileContent, + file, + files, + autorefresh, + isPlaying, + clearConsole, + startSketch, + autocompleteHinter, + fontSize, + onUpdateLinting +}) { + // The codemirror instance. + const cmInstance = useRef(); + // The current codemirror files. + const docs = useRef(); + + function onKeyUp() { + const lineNumber = parseInt(cmInstance.current.getCursor().line + 1, 10); + setCurrentLine(lineNumber); + } + + function onKeyDown(_cm, e) { + // Show hint + const mode = cmInstance.current.getOption('mode'); + if (/^[a-z]$/i.test(e.key) && (mode === 'css' || mode === 'javascript')) { + showHint(_cm, autocompleteHinter, fontSize); + } + if (e.key === 'Escape') { + e.preventDefault(); + const selections = cmInstance.current.listSelections(); + + if (selections.length > 1) { + const firstPos = selections[0].head || selections[0].anchor; + cmInstance.current.setSelection(firstPos); + cmInstance.current.scrollIntoView(firstPos); + } else { + cmInstance.current.getInputField().blur(); + } + } + } + + // We have to create a ref for the file ID, or else the debouncer + // will old onto an old version of the fileId and just overrwrite the initial file. + const fileId = useRef(); + fileId.current = file.id; + + // When the file changes, update the file content and save status. + function onChange() { + setUnsavedChanges(true); + hideRuntimeErrorWarning(); + updateFileContent(fileId.current, cmInstance.current.getValue()); + if (autorefresh && isPlaying) { + clearConsole(); + startSketch(); + } + } + const debouncedOnChange = debounce(onChange, 1000); + + // When the container component enters the DOM, we want this function + // to be called so we can setup the CodeMirror instance with the container. + function setupCodeMirrorOnContainerMounted(container) { + cmInstance.current = CodeMirror(container, { + theme: `p5-${theme}`, + lineNumbers, + styleActiveLine: true, + inputStyle: 'contenteditable', + lineWrapping: linewrap, + fixedGutter: false, + foldGutter: true, + foldOptions: { widget: '\u2026' }, + gutters: ['CodeMirror-foldgutter', 'CodeMirror-lint-markers'], + keyMap: 'sublime', + highlightSelectionMatches: true, // highlight current search match + matchBrackets: true, + emmet: { + preview: ['html'], + markTagPairs: true, + autoRenameTags: true + }, + autoCloseBrackets: autocloseBracketsQuotes, + styleSelectedText: true, + lint: { + onUpdateLinting, + options: { + asi: true, + eqeqeq: false, + '-W041': false, + esversion: 11 + } + }, + colorpicker: { + type: 'sketch', + mode: 'edit' + } + }); + + delete cmInstance.current.options.lint.options.errors; + + const replaceCommand = + metaKey === 'Ctrl' ? `${metaKey}-H` : `${metaKey}-Option-F`; + cmInstance.current.setOption('extraKeys', { + Tab: (tabCm) => { + if (!tabCm.execCommand('emmetExpandAbbreviation')) return; + // might need to specify and indent more? + const selection = tabCm.doc.getSelection(); + if (selection.length > 0) { + tabCm.execCommand('indentMore'); + } else { + tabCm.replaceSelection(' '.repeat(INDENTATION_AMOUNT)); + } + }, + Enter: 'emmetInsertLineBreak', + Esc: 'emmetResetAbbreviation', + [`Shift-Tab`]: false, + [`${metaKey}-Enter`]: () => null, + [`Shift-${metaKey}-Enter`]: () => null, + [`${metaKey}-F`]: 'findPersistent', + [`Shift-${metaKey}-F`]: () => tidyCode(cmInstance.current), + [`${metaKey}-G`]: 'findPersistentNext', + [`Shift-${metaKey}-G`]: 'findPersistentPrev', + [replaceCommand]: 'replace', + // Cassie Tarakajian: If you don't set a default color, then when you + // choose a color, it deletes characters inline. This is a + // hack to prevent that. + [`${metaKey}-K`]: (metaCm, event) => + metaCm.state.colorpicker.popup_color_picker({ length: 0 }), + [`${metaKey}-.`]: 'toggleComment' // Note: most adblockers use the shortcut ctrl+. + }); + + // Setup the event listeners on the CodeMirror instance. + cmInstance.current.on('change', debouncedOnChange); + cmInstance.current.on('keyup', onKeyUp); + cmInstance.current.on('keydown', onKeyDown); + + cmInstance.current.getWrapperElement().style['font-size'] = `${fontSize}px`; + } + + // When settings change, we pass those changes into CodeMirror. + useEffect(() => { + cmInstance.current.getWrapperElement().style['font-size'] = `${fontSize}px`; + }, [fontSize]); + useEffect(() => { + cmInstance.current.setOption('lineWrapping', linewrap); + }, [linewrap]); + useEffect(() => { + cmInstance.current.setOption('theme', `p5-${theme}`); + }, [theme]); + useEffect(() => { + cmInstance.current.setOption('lineNumbers', lineNumbers); + }, [lineNumbers]); + useEffect(() => { + cmInstance.current.setOption('autoCloseBrackets', autocloseBracketsQuotes); + }, [autocloseBracketsQuotes]); + + // Initializes the files as CodeMirror documents. + function initializeDocuments() { + docs.current = {}; + files.forEach((currentFile) => { + if (currentFile.name !== 'root') { + docs.current[currentFile.id] = CodeMirror.Doc( + currentFile.content, + getFileMode(currentFile.name) + ); + } + }); + } + + // When the files change, reinitialize the documents. + useEffect(initializeDocuments, [files]); + + // When the file changes, we change the file mode and + // make the CodeMirror call to swap out the document. + useEffectWithComparison( + (_, prevProps) => { + const fileMode = getFileMode(file.name); + if (fileMode === 'javascript') { + // Define the new Emmet configuration based on the file mode + const emmetConfig = { + preview: ['html'], + markTagPairs: false, + autoRenameTags: true + }; + cmInstance.current.setOption('emmet', emmetConfig); + } + const oldDoc = cmInstance.current.swapDoc(docs.current[file.id]); + if (prevProps?.file) { + docs.current[prevProps.file.id] = oldDoc; + } + cmInstance.current.focus(); + + for (let i = 0; i < cmInstance.current.lineCount(); i += 1) { + cmInstance.current.removeLineClass( + i, + 'background', + 'line-runtime-error' + ); + } + }, + [file.id] + ); + + // Remove the CM listeners on component teardown. + function teardownCodeMirror() { + cmInstance.current.off('keyup', onKeyUp); + cmInstance.current.off('change', debouncedOnChange); + cmInstance.current.off('keydown', onKeyDown); + } + + const getContent = () => { + const content = cmInstance.current.getValue(); + const updatedFile = Object.assign({}, file, { content }); + return updatedFile; + }; + + const showFind = () => { + cmInstance.current.execCommand('findPersistent'); + }; + + const showReplace = () => { + cmInstance.current.execCommand('replace'); + }; + + return { + setupCodeMirrorOnContainerMounted, + teardownCodeMirror, + cmInstance, + getContent, + showFind, + showReplace + }; +} diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 4ef69d2a44..bf1eca9882 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -1,34 +1,7 @@ -// TODO: convert to functional component - import PropTypes from 'prop-types'; -import React from 'react'; -import CodeMirror from 'codemirror'; -import emmet from '@emmetio/codemirror-plugin'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { withTranslation } from 'react-i18next'; import StackTrace from 'stacktrace-js'; -import 'codemirror/mode/css/css'; -import 'codemirror/mode/clike/clike'; -import 'codemirror/addon/selection/active-line'; -import 'codemirror/addon/lint/lint'; -import 'codemirror/addon/lint/javascript-lint'; -import 'codemirror/addon/lint/css-lint'; -import 'codemirror/addon/lint/html-lint'; -import 'codemirror/addon/fold/brace-fold'; -import 'codemirror/addon/fold/comment-fold'; -import 'codemirror/addon/fold/foldcode'; -import 'codemirror/addon/fold/foldgutter'; -import 'codemirror/addon/fold/indent-fold'; -import 'codemirror/addon/fold/xml-fold'; -import 'codemirror/addon/comment/comment'; -import 'codemirror/keymap/sublime'; -import 'codemirror/addon/search/searchcursor'; -import 'codemirror/addon/search/matchesonscrollbar'; -import 'codemirror/addon/search/match-highlighter'; -import 'codemirror/addon/search/jump-to-line'; -import 'codemirror/addon/edit/matchbrackets'; -import 'codemirror/addon/edit/closebrackets'; -import 'codemirror/addon/selection/mark-selection'; -import 'codemirror-colorpicker'; import classNames from 'classnames'; import { debounce } from 'lodash'; @@ -37,7 +10,6 @@ import { bindActionCreators } from 'redux'; import MediaQuery from 'react-responsive'; import '../../../../utils/htmlmixed'; import '../../../../utils/p5-javascript'; -import { metaKey } from '../../../../utils/metaKey'; import '../../../../utils/codemirror-search'; import beepUrl from '../../../../sounds/audioAlert.mp3'; @@ -62,395 +34,242 @@ import { EditorContainer, EditorHolder } from './MobileEditor'; import { FolderIcon } from '../../../../common/icons'; import IconButton from '../../../../common/IconButton'; -import { showHint, hideHinter } from './hinter'; -import getFileMode from './utils'; +import { hideHinter } from './hinter'; import tidyCode from './tidier'; - -emmet(CodeMirror); - -const INDENTATION_AMOUNT = 2; - -class Editor extends React.Component { - constructor(props) { - super(props); - this.state = { - currentLine: 1 - }; - this._cm = null; - - this.updateLintingMessageAccessibility = debounce((annotations) => { - this.props.clearLintMessage(); - annotations.forEach((x) => { - if (x.from.line > -1) { - this.props.updateLintMessage(x.severity, x.from.line + 1, x.message); - } - }); - if (this.props.lintMessages.length > 0 && this.props.lintWarning) { - this.beep.play(); - } - }, 2000); - this.showFind = this.showFind.bind(this); - this.showReplace = this.showReplace.bind(this); - this.getContent = this.getContent.bind(this); - } - - componentDidMount() { - this.beep = new Audio(beepUrl); - // this.widgets = []; - this._cm = CodeMirror(this.codemirrorContainer, { - theme: `p5-${this.props.theme}`, - lineNumbers: this.props.lineNumbers, - styleActiveLine: true, - inputStyle: 'contenteditable', - lineWrapping: this.props.linewrap, - fixedGutter: false, - foldGutter: true, - foldOptions: { widget: '\u2026' }, - gutters: ['CodeMirror-foldgutter', 'CodeMirror-lint-markers'], - keyMap: 'sublime', - highlightSelectionMatches: true, // highlight current search match - matchBrackets: true, - emmet: { - preview: ['html'], - markTagPairs: true, - autoRenameTags: true - }, - autoCloseBrackets: this.props.autocloseBracketsQuotes, - styleSelectedText: true, - lint: { - onUpdateLinting: (annotations) => { - this.updateLintingMessageAccessibility(annotations); - }, - options: { - asi: true, - eqeqeq: false, - '-W041': false, - esversion: 11 - } - }, - colorpicker: { - type: 'sketch', - mode: 'edit' +import useCodeMirror from './codemirror'; +import { useEffectWithComparison } from '../../hooks/custom-hooks'; + +function Editor({ + provideController, + files, + file, + theme, + linewrap, + lineNumbers, + closeProjectOptions, + setSelectedFile, + setUnsavedChanges, + lintMessages, + lintWarning, + clearLintMessage, + updateLintMessage, + updateFileContent, + autorefresh, + isPlaying, + clearConsole, + startSketch, + autocompleteHinter, + autocloseBracketsQuotes, + fontSize, + consoleEvents, + hideRuntimeErrorWarning, + runtimeErrorWarningVisible, + expandConsole, + isExpanded, + t, + collapseSidebar, + expandSidebar +}) { + const [currentLine, setCurrentLine] = useState(1); + const beep = useRef(); + + const updateLintingMessageAccessibility = debounce((annotations) => { + clearLintMessage(); + annotations.forEach((x) => { + if (x.from.line > -1) { + updateLintMessage(x.severity, x.from.line + 1, x.message); } }); - - delete this._cm.options.lint.options.errors; - - const replaceCommand = - metaKey === 'Ctrl' ? `${metaKey}-H` : `${metaKey}-Option-F`; - this._cm.setOption('extraKeys', { - Tab: (cm) => { - if (!cm.execCommand('emmetExpandAbbreviation')) return; - // might need to specify and indent more? - const selection = cm.doc.getSelection(); - if (selection.length > 0) { - cm.execCommand('indentMore'); - } else { - cm.replaceSelection(' '.repeat(INDENTATION_AMOUNT)); - } - }, - Enter: 'emmetInsertLineBreak', - Esc: 'emmetResetAbbreviation', - [`Shift-Tab`]: false, - [`${metaKey}-Enter`]: () => null, - [`Shift-${metaKey}-Enter`]: () => null, - [`${metaKey}-F`]: 'findPersistent', - [`Shift-${metaKey}-F`]: () => tidyCode(this._cm), - [`${metaKey}-G`]: 'findPersistentNext', - [`Shift-${metaKey}-G`]: 'findPersistentPrev', - [replaceCommand]: 'replace', - // Cassie Tarakajian: If you don't set a default color, then when you - // choose a color, it deletes characters inline. This is a - // hack to prevent that. - [`${metaKey}-K`]: (cm, event) => - cm.state.colorpicker.popup_color_picker({ length: 0 }), - [`${metaKey}-.`]: 'toggleComment' // Note: most adblockers use the shortcut ctrl+. - }); - - this.initializeDocuments(this.props.files); - this._cm.swapDoc(this._docs[this.props.file.id]); - - this._cm.on( - 'change', - debounce(() => { - this.props.setUnsavedChanges(true); - this.props.hideRuntimeErrorWarning(); - this.props.updateFileContent(this.props.file.id, this._cm.getValue()); - if (this.props.autorefresh && this.props.isPlaying) { - this.props.clearConsole(); - this.props.startSketch(); - } - }, 1000) - ); - - if (this._cm) { - this._cm.on('keyup', this.handleKeyUp); + if (lintMessages.length > 0 && lintWarning) { + beep.play(); } - - this._cm.on('keydown', (_cm, e) => { - // Show hint - const mode = this._cm.getOption('mode'); - if (/^[a-z]$/i.test(e.key) && (mode === 'css' || mode === 'javascript')) { - showHint(_cm, this.props.autocompleteHinter, this.props.fontSize); - } + }, 2000); + + // The useCodeMirror hook manages CodeMirror state and returns + // a reference to the actual CM instance. + const { + setupCodeMirrorOnContainerMounted, + teardownCodeMirror, + cmInstance, + getContent, + showFind, + showReplace + } = useCodeMirror({ + theme, + lineNumbers, + linewrap, + autocloseBracketsQuotes, + setUnsavedChanges, + hideRuntimeErrorWarning, + updateFileContent, + file, + files, + autorefresh, + isPlaying, + clearConsole, + startSketch, + autocompleteHinter, + fontSize, + updateLintingMessageAccessibility, + setCurrentLine + }); + + // Lets the parent component access file content-specific functionality... + useEffect(() => { + provideController({ + tidyCode: () => tidyCode(cmInstance.current), + showFind, + showReplace, + getContent }); + }, [showFind, showReplace, getContent]); - this._cm.getWrapperElement().style[ - 'font-size' - ] = `${this.props.fontSize}px`; + // When the CM container div mounts, we set up CodeMirror. + const onContainerMounted = useCallback(setupCodeMirrorOnContainerMounted, []); - this.props.provideController({ - tidyCode: () => tidyCode(this._cm), - showFind: this.showFind, - showReplace: this.showReplace, - getContent: this.getContent - }); - } + // This is acting as a "componentDidMount" call where it runs once + // at the start and never again. It also provides a cleanup function. + useEffect(() => { + beep.current = new Audio(beepUrl); - componentWillUpdate(nextProps) { - // check if files have changed - if (this.props.files[0].id !== nextProps.files[0].id) { - // then need to make CodeMirror documents - this.initializeDocuments(nextProps.files); - } - if (this.props.files.length !== nextProps.files.length) { - this.initializeDocuments(nextProps.files); - } - } - - componentDidUpdate(prevProps) { - if (this.props.file.id !== prevProps.file.id) { - const fileMode = getFileMode(this.props.file.name); - if (fileMode === 'javascript') { - // Define the new Emmet configuration based on the file mode - const emmetConfig = { - preview: ['html'], - markTagPairs: false, - autoRenameTags: true - }; - this._cm.setOption('emmet', emmetConfig); - } - 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); - } - } - if (this.props.fontSize !== prevProps.fontSize) { - this._cm.getWrapperElement().style[ - 'font-size' - ] = `${this.props.fontSize}px`; - } - if (this.props.linewrap !== prevProps.linewrap) { - this._cm.setOption('lineWrapping', this.props.linewrap); - } - if (this.props.theme !== prevProps.theme) { - this._cm.setOption('theme', `p5-${this.props.theme}`); - } - if (this.props.lineNumbers !== prevProps.lineNumbers) { - this._cm.setOption('lineNumbers', this.props.lineNumbers); - } - if ( - this.props.autocloseBracketsQuotes !== prevProps.autocloseBracketsQuotes - ) { - this._cm.setOption( - 'autoCloseBrackets', - this.props.autocloseBracketsQuotes - ); - } - if (this.props.autocompleteHinter !== prevProps.autocompleteHinter) { - if (!this.props.autocompleteHinter) { - // close the hinter window once the preference is turned off - hideHinter(this._cm); - } - } + provideController({ + tidyCode: () => tidyCode(cmInstance.current), + showFind, + showReplace, + getContent + }); - if (this.props.runtimeErrorWarningVisible) { - if (this.props.consoleEvents.length !== prevProps.consoleEvents.length) { - this.props.consoleEvents.forEach((consoleEvent) => { - if (consoleEvent.method === 'error') { - // It doesn't work if you create a new Error, but this works - // LOL - const errorObj = { stack: consoleEvent.data[0].toString() }; - StackTrace.fromError(errorObj).then((stackLines) => { - this.props.expandConsole(); - const line = stackLines.find( - (l) => l.fileName && l.fileName.startsWith('/') - ); - if (!line) return; - const fileNameArray = line.fileName.split('/'); - const fileName = fileNameArray.slice(-1)[0]; - const filePath = fileNameArray.slice(0, -1).join('/'); - const fileWithError = this.props.files.find( - (f) => f.name === fileName && f.filePath === filePath - ); - this.props.setSelectedFile(fileWithError.id); - this._cm.addLineClass( - line.lineNumber - 1, - 'background', - 'line-runtime-error' - ); - }); + return () => { + provideController(null); + teardownCodeMirror(); + }; + }, []); + + useEffect(() => { + // Close the hinter window once the preference is turned off + if (!autocompleteHinter) hideHinter(cmInstance.current); + }, [autocompleteHinter]); + + // Updates the error console. + useEffectWithComparison( + (_, prevProps) => { + if (runtimeErrorWarningVisible) { + if ( + prevProps.consoleEvents && + consoleEvents.length !== prevProps.consoleEvents.length + ) { + consoleEvents.forEach((consoleEvent) => { + if (consoleEvent.method === 'error') { + // It doesn't work if you create a new Error, but this works + // LOL + const errorObj = { stack: consoleEvent.data[0].toString() }; + StackTrace.fromError(errorObj).then((stackLines) => { + expandConsole(); + const line = stackLines.find( + (l) => l.fileName && l.fileName.startsWith('/') + ); + if (!line) return; + const fileNameArray = line.fileName.split('/'); + const fileName = fileNameArray.slice(-1)[0]; + const filePath = fileNameArray.slice(0, -1).join('/'); + const fileWithError = files.find( + (f) => f.name === fileName && f.filePath === filePath + ); + setSelectedFile(fileWithError.id); + cmInstance.current.addLineClass( + line.lineNumber - 1, + 'background', + 'line-runtime-error' + ); + }); + } + }); + } else { + for (let i = 0; i < cmInstance.current.lineCount(); i += 1) { + cmInstance.current.removeLineClass( + i, + 'background', + 'line-runtime-error' + ); } - }); - } else { - for (let i = 0; i < this._cm.lineCount(); i += 1) { - this._cm.removeLineClass(i, 'background', 'line-runtime-error'); } } - } - - if (this.props.file.id !== prevProps.file.id) { - for (let i = 0; i < this._cm.lineCount(); i += 1) { - this._cm.removeLineClass(i, 'background', 'line-runtime-error'); - } - } - - this.props.provideController({ - tidyCode: () => tidyCode(this._cm), - showFind: this.showFind, - showReplace: this.showReplace, - getContent: this.getContent - }); - } - - componentWillUnmount() { - if (this._cm) { - this._cm.off('keyup', this.handleKeyUp); - } - this.props.provideController(null); - } - - getContent() { - const content = this._cm.getValue(); - const updatedFile = Object.assign({}, this.props.file, { content }); - return updatedFile; - } - - handleKeyUp = () => { - const lineNumber = parseInt(this._cm.getCursor().line + 1, 10); - this.setState({ currentLine: lineNumber }); - }; - - showFind() { - this._cm.execCommand('findPersistent'); - } - - showReplace() { - this._cm.execCommand('replace'); - } - - initializeDocuments(files) { - this._docs = {}; - files.forEach((file) => { - if (file.name !== 'root') { - this._docs[file.id] = CodeMirror.Doc( - file.content, - getFileMode(file.name) - ); // eslint-disable-line - } - }); - } - - render() { - const editorSectionClass = classNames({ - editor: true, - 'sidebar--contracted': !this.props.isExpanded - }); - - const editorHolderClass = classNames({ - 'editor-holder': true, - 'editor-holder--hidden': - this.props.file.fileType === 'folder' || this.props.file.url - }); - - const { currentLine } = this.state; + }, + [consoleEvents, runtimeErrorWarningVisible] + ); - return ( - <MediaQuery minWidth={770}> - {(matches) => - matches ? ( - <section className={editorSectionClass}> - <div className="editor__header"> - <button - aria-label={this.props.t('Editor.OpenSketchARIA')} - className="sidebar__contract" - onClick={() => { - this.props.collapseSidebar(); - this.props.closeProjectOptions(); - }} - > - <LeftArrowIcon focusable="false" aria-hidden="true" /> - </button> - <button - aria-label={this.props.t('Editor.CloseSketchARIA')} - className="sidebar__expand" - onClick={this.props.expandSidebar} - > - <RightArrowIcon focusable="false" aria-hidden="true" /> - </button> - <div className="editor__file-name"> - <span> - {this.props.file.name} - <UnsavedChangesIndicator /> - </span> - <Timer /> - </div> - </div> - <article - ref={(element) => { - this.codemirrorContainer = element; + const editorSectionClass = classNames({ + editor: true, + 'sidebar--contracted': !isExpanded + }); + + const editorHolderClass = classNames({ + 'editor-holder': true, + 'editor-holder--hidden': file.fileType === 'folder' || file.url + }); + + return ( + <MediaQuery minWidth={770}> + {(matches) => + matches ? ( + <section className={editorSectionClass}> + <div className="editor__header"> + <button + aria-label={t('Editor.OpenSketchARIA')} + className="sidebar__contract" + onClick={() => { + collapseSidebar(); + closeProjectOptions(); }} - className={editorHolderClass} - /> - {this.props.file.url ? ( - <AssetPreview - url={this.props.file.url} - name={this.props.file.name} - /> + > + <LeftArrowIcon focusable="false" aria-hidden="true" /> + </button> + <button + aria-label={t('Editor.CloseSketchARIA')} + className="sidebar__expand" + onClick={expandSidebar} + > + <RightArrowIcon focusable="false" aria-hidden="true" /> + </button> + <div className="editor__file-name"> + <span> + {file.name} + <UnsavedChangesIndicator /> + </span> + <Timer /> + </div> + </div> + <article ref={onContainerMounted} className={editorHolderClass} /> + {file.url ? <AssetPreview url={file.url} name={file.name} /> : null} + <EditorAccessibility + lintMessages={lintMessages} + currentLine={currentLine} + /> + </section> + ) : ( + <EditorContainer expanded={isExpanded}> + <div> + <IconButton onClick={expandSidebar} icon={FolderIcon} /> + <span> + {file.name} + <UnsavedChangesIndicator /> + </span> + </div> + <section> + <EditorHolder ref={onContainerMounted} /> + {file.url ? ( + <AssetPreview url={file.url} name={file.name} /> ) : null} <EditorAccessibility - lintMessages={this.props.lintMessages} + lintMessages={lintMessages} currentLine={currentLine} /> </section> - ) : ( - <EditorContainer expanded={this.props.isExpanded}> - <div> - <IconButton - onClick={this.props.expandSidebar} - icon={FolderIcon} - /> - <span> - {this.props.file.name} - <UnsavedChangesIndicator /> - </span> - </div> - <section> - <EditorHolder - ref={(element) => { - this.codemirrorContainer = element; - }} - /> - {this.props.file.url ? ( - <AssetPreview - url={this.props.file.url} - name={this.props.file.name} - /> - ) : null} - <EditorAccessibility - lintMessages={this.props.lintMessages} - currentLine={currentLine} - /> - </section> - </EditorContainer> - ) - } - </MediaQuery> - ); - } + </EditorContainer> + ) + } + </MediaQuery> + ); } Editor.propTypes = { @@ -490,7 +309,6 @@ Editor.propTypes = { autorefresh: PropTypes.bool.isRequired, isPlaying: PropTypes.bool.isRequired, theme: PropTypes.string.isRequired, - unsavedChanges: PropTypes.bool.isRequired, files: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string.isRequired,