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/hinter.js b/client/modules/IDE/components/Editor/hinter.js new file mode 100644 index 0000000000..81bb73d353 --- /dev/null +++ b/client/modules/IDE/components/Editor/hinter.js @@ -0,0 +1,118 @@ +import Fuse from 'fuse.js'; +import CodeMirror from 'codemirror'; +import { JSHINT } from 'jshint'; +import { HTMLHint } from 'htmlhint'; +import { CSSLint } from 'csslint'; + +import 'codemirror/addon/hint/css-hint'; +import * as hinterDefinition from '../../../../utils/p5-hinter'; +import '../show-hint'; // TODO: Remove for codemirror v6? + +window.JSHINT = JSHINT; +window.CSSLint = CSSLint; +window.HTMLHint = HTMLHint; + +const hinter = new Fuse(hinterDefinition.p5Hinter, { + threshold: 0.05, + keys: ['text'] +}); + +/** Hides the hinter. */ +export function hideHinter(cmInstance) { + CodeMirror.showHint(cmInstance, () => {}, {}); +} + +/** + * Shows a hint popup in the codemirror instance. + * It will only be visible if the user has autocomplete on in the settings. + */ +export function showHint(cmInstance, autocompleteHinter, fontSize) { + if (!autocompleteHinter) { + CodeMirror.showHint(cmInstance, () => {}, {}); + return; + } + + let focusedLinkElement = null; + const setFocusedLinkElement = (set) => { + if (set && !focusedLinkElement) { + const activeItemLink = document.querySelector( + `.CodeMirror-hint-active a` + ); + if (activeItemLink) { + focusedLinkElement = activeItemLink; + focusedLinkElement.classList.add('focused-hint-link'); + focusedLinkElement.parentElement.parentElement.classList.add( + 'unfocused' + ); + } + } + }; + const removeFocusedLinkElement = () => { + if (focusedLinkElement) { + focusedLinkElement.classList.remove('focused-hint-link'); + focusedLinkElement.parentElement.parentElement.classList.remove( + 'unfocused' + ); + focusedLinkElement = null; + return true; + } + return false; + }; + + const hintOptions = { + _fontSize: fontSize, + completeSingle: false, + extraKeys: { + 'Shift-Right': (cm, e) => { + const activeItemLink = document.querySelector( + `.CodeMirror-hint-active a` + ); + if (activeItemLink) activeItemLink.click(); + }, + Right: (cm, e) => { + setFocusedLinkElement(true); + }, + Left: (cm, e) => { + removeFocusedLinkElement(); + }, + Up: (cm, e) => { + const onLink = removeFocusedLinkElement(); + e.moveFocus(-1); + setFocusedLinkElement(onLink); + }, + Down: (cm, e) => { + const onLink = removeFocusedLinkElement(); + e.moveFocus(1); + setFocusedLinkElement(onLink); + }, + Enter: (cm, e) => { + if (focusedLinkElement) focusedLinkElement.click(); + else e.pick(); + } + }, + closeOnUnfocus: false + }; + + if (cmInstance.options.mode === 'javascript') { + CodeMirror.showHint( + cmInstance, + () => { + const cursor = cmInstance.getCursor(); + const token = cmInstance.getTokenAt(cursor); + + const hints = hinter + .search(token.string) + .filter((h) => h.item.text[0] === token.string[0]); + + return { + list: hints, + from: CodeMirror.Pos(cursor.line, token.start), + to: CodeMirror.Pos(cursor.line, cursor.ch) + }; + }, + hintOptions + ); + } else if (cmInstance.options.mode === 'css') { + CodeMirror.showHint(cmInstance, CodeMirror.hint.css, hintOptions); + } +} diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 90a0b450fb..bf1eca9882 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -1,44 +1,8 @@ -// TODO: convert to functional component - import PropTypes from 'prop-types'; -import React from 'react'; -import CodeMirror from 'codemirror'; -import Fuse from 'fuse.js'; -import emmet from '@emmetio/codemirror-plugin'; -import prettier from 'prettier/standalone'; -import babelParser from 'prettier/parser-babel'; -import htmlParser from 'prettier/parser-html'; -import cssParser from 'prettier/parser-postcss'; +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/addon/hint/css-hint'; -import 'codemirror-colorpicker'; -import { JSHINT } from 'jshint'; -import { CSSLint } from 'csslint'; -import { HTMLHint } from 'htmlhint'; import classNames from 'classnames'; import { debounce } from 'lodash'; import { connect } from 'react-redux'; @@ -46,9 +10,6 @@ import { bindActionCreators } from 'redux'; import MediaQuery from 'react-responsive'; import '../../../../utils/htmlmixed'; import '../../../../utils/p5-javascript'; -import { metaKey } from '../../../../utils/metaKey'; -import '../show-hint'; -import * as hinter from '../../../../utils/p5-hinter'; import '../../../../utils/codemirror-search'; import beepUrl from '../../../../sounds/audioAlert.mp3'; @@ -73,561 +34,242 @@ import { EditorContainer, EditorHolder } from './MobileEditor'; import { FolderIcon } from '../../../../common/icons'; import IconButton from '../../../../common/IconButton'; -emmet(CodeMirror); - -window.JSHINT = JSHINT; -window.CSSLint = CSSLint; -window.HTMLHint = HTMLHint; - -const INDENTATION_AMOUNT = 2; - -class Editor extends React.Component { - constructor(props) { - super(props); - this.state = { - currentLine: 1 - }; - this._cm = null; - this.tidyCode = this.tidyCode.bind(this); - - 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); - this.updateFileContent = this.updateFileContent.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 { hideHinter } from './hinter'; +import tidyCode from './tidier'; +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); } }); - - this.hinter = new Fuse(hinter.p5Hinter, { - threshold: 0.05, - keys: ['text'] - }); - - 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`]: this.tidyCode, - [`${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')) { - this.showHint(_cm); - } - }); - - this._cm.getWrapperElement().style[ - 'font-size' - ] = `${this.props.fontSize}px`; - - this.props.provideController({ - tidyCode: this.tidyCode, - showFind: this.showFind, - showReplace: this.showReplace, - getContent: this.getContent, - updateFileContent: this.updateFileContent + }, 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]); - 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); - } - } + // When the CM container div mounts, we set up CodeMirror. + const onContainerMounted = useCallback(setupCodeMirrorOnContainerMounted, []); - componentDidUpdate(prevProps) { - if (this.props.file.id !== prevProps.file.id) { - const fileMode = this.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(); + // 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); - if (!prevProps.unsavedChanges) { - setTimeout(() => this.props.setUnsavedChanges(false), 400); - } - } else if (this.getContent().content !== this.props.file.content) { - // TODO: make this not break regular edits! - // this._cm.setValue(this.props.file.content); - } - 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 - CodeMirror.showHint(this._cm, () => {}, {}); - } - } - - 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' - ); - }); - } - }); - } 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: this.tidyCode, - showFind: this.showFind, - showReplace: this.showReplace, - getContent: this.getContent, - updateFileContent: this.updateFileContent + provideController({ + tidyCode: () => tidyCode(cmInstance.current), + showFind, + showReplace, + getContent }); - } - componentWillUnmount() { - if (this._cm) { - this._cm.off('keyup', this.handleKeyUp); - } - this.props.provideController(null); - } - - getFileMode(fileName) { - let mode; - if (fileName.match(/.+\.js$/i)) { - mode = 'javascript'; - } else if (fileName.match(/.+\.css$/i)) { - mode = 'css'; - } else if (fileName.match(/.+\.(html|xml)$/i)) { - mode = 'htmlmixed'; - } else if (fileName.match(/.+\.json$/i)) { - mode = 'application/json'; - } else if (fileName.match(/.+\.(frag|glsl)$/i)) { - mode = 'x-shader/x-fragment'; - } else if (fileName.match(/.+\.(vert|stl|mtl)$/i)) { - mode = 'x-shader/x-vertex'; - } else { - mode = 'text/plain'; - } - return mode; - } - - getContent() { - const content = this._cm.getValue(); - const updatedFile = Object.assign({}, this.props.file, { content }); - return updatedFile; - } - - updateFileContent(id, src) { - const file = this._docs[id]; - if (file) { - this._docs[id] = CodeMirror.Doc(src, this._docs[id].modeOption); - if (id === this.props.file.id) { - this._cm.swapDoc(this._docs[id]); - } - } - } - - handleKeyUp = () => { - const lineNumber = parseInt(this._cm.getCursor().line + 1, 10); - this.setState({ currentLine: lineNumber }); - }; - - showFind() { - this._cm.execCommand('findPersistent'); - } - - showHint(_cm) { - if (!this.props.autocompleteHinter) { - CodeMirror.showHint(_cm, () => {}, {}); - return; - } - - let focusedLinkElement = null; - const setFocusedLinkElement = (set) => { - if (set && !focusedLinkElement) { - const activeItemLink = document.querySelector( - `.CodeMirror-hint-active a` - ); - if (activeItemLink) { - focusedLinkElement = activeItemLink; - focusedLinkElement.classList.add('focused-hint-link'); - focusedLinkElement.parentElement.parentElement.classList.add( - 'unfocused' - ); - } - } + return () => { + provideController(null); + teardownCodeMirror(); }; - const removeFocusedLinkElement = () => { - if (focusedLinkElement) { - focusedLinkElement.classList.remove('focused-hint-link'); - focusedLinkElement.parentElement.parentElement.classList.remove( - 'unfocused' - ); - focusedLinkElement = null; - return true; - } - return false; - }; - - const hintOptions = { - _fontSize: this.props.fontSize, - completeSingle: false, - extraKeys: { - 'Shift-Right': (cm, e) => { - const activeItemLink = document.querySelector( - `.CodeMirror-hint-active a` - ); - if (activeItemLink) activeItemLink.click(); - }, - Right: (cm, e) => { - setFocusedLinkElement(true); - }, - Left: (cm, e) => { - removeFocusedLinkElement(); - }, - Up: (cm, e) => { - const onLink = removeFocusedLinkElement(); - e.moveFocus(-1); - setFocusedLinkElement(onLink); - }, - Down: (cm, e) => { - const onLink = removeFocusedLinkElement(); - e.moveFocus(1); - setFocusedLinkElement(onLink); - }, - Enter: (cm, e) => { - if (focusedLinkElement) focusedLinkElement.click(); - else e.pick(); - } - }, - closeOnUnfocus: false - }; - - if (_cm.options.mode === 'javascript') { - // JavaScript - CodeMirror.showHint( - _cm, - () => { - const c = _cm.getCursor(); - const token = _cm.getTokenAt(c); - - const hints = this.hinter - .search(token.string) - .filter((h) => h.item.text[0] === token.string[0]); - - return { - list: hints, - from: CodeMirror.Pos(c.line, token.start), - to: CodeMirror.Pos(c.line, c.ch) - }; - }, - hintOptions - ); - } else if (_cm.options.mode === 'css') { - // CSS - CodeMirror.showHint(_cm, CodeMirror.hint.css, hintOptions); - } - } - - showReplace() { - this._cm.execCommand('replace'); - } - - prettierFormatWithCursor(parser, plugins) { - try { - const { formatted, cursorOffset } = prettier.formatWithCursor( - this._cm.doc.getValue(), - { - cursorOffset: this._cm.doc.indexFromPos(this._cm.doc.getCursor()), - parser, - plugins + }, []); + + 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' + ); + } } - ); - const { left, top } = this._cm.getScrollInfo(); - this._cm.doc.setValue(formatted); - this._cm.focus(); - this._cm.doc.setCursor(this._cm.doc.posFromIndex(cursorOffset)); - this._cm.scrollTo(left, top); - } catch (error) { - console.error(error); - } - } - - tidyCode() { - const mode = this._cm.getOption('mode'); - if (mode === 'javascript') { - this.prettierFormatWithCursor('babel', [babelParser]); - } else if (mode === 'css') { - this.prettierFormatWithCursor('css', [cssParser]); - } else if (mode === 'htmlmixed') { - this.prettierFormatWithCursor('html', [htmlParser]); - } - } - - initializeDocuments(files) { - this._docs = {}; - files.forEach((file) => { - if (file.name !== 'root') { - this._docs[file.id] = CodeMirror.Doc( - file.content, - this.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 ( - - {(matches) => - matches ? ( -
-
- - -
- - {this.props.file.name} - - - -
-
-
{ - 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 ( + + {(matches) => + matches ? ( +
+
+ + +
+ + {file.name} + + + +
+
+
+ {file.url ? : null} + +
+ ) : ( + +
+ + + {file.name} + + +
+
+ + {file.url ? ( + ) : null}
- ) : ( - -
- - - {this.props.file.name} - - -
-
- { - this.codemirrorContainer = element; - }} - /> - {this.props.file.url ? ( - - ) : null} - -
-
- ) - } -
- ); - } + + ) + } + + ); } Editor.propTypes = { @@ -667,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, diff --git a/client/modules/IDE/components/Editor/tidier.js b/client/modules/IDE/components/Editor/tidier.js new file mode 100644 index 0000000000..cadd601c15 --- /dev/null +++ b/client/modules/IDE/components/Editor/tidier.js @@ -0,0 +1,36 @@ +import prettier from 'prettier/standalone'; +import babelParser from 'prettier/parser-babel'; +import htmlParser from 'prettier/parser-html'; +import cssParser from 'prettier/parser-postcss'; + +function prettierFormatWithCursor(parser, plugins, cmInstance) { + try { + const { formatted, cursorOffset } = prettier.formatWithCursor( + cmInstance.doc.getValue(), + { + cursorOffset: cmInstance.doc.indexFromPos(cmInstance.doc.getCursor()), + parser, + plugins + } + ); + const { left, top } = cmInstance.getScrollInfo(); + cmInstance.doc.setValue(formatted); + cmInstance.focus(); + cmInstance.doc.setCursor(cmInstance.doc.posFromIndex(cursorOffset)); + cmInstance.scrollTo(left, top); + } catch (error) { + console.error(error); + } +} + +/** Runs prettier on the codemirror instance, depending on the mode. */ +export default function tidyCode(cmInstance) { + const mode = cmInstance.getOption('mode'); + if (mode === 'javascript') { + prettierFormatWithCursor('babel', [babelParser], cmInstance); + } else if (mode === 'css') { + prettierFormatWithCursor('css', [cssParser], cmInstance); + } else if (mode === 'htmlmixed') { + prettierFormatWithCursor('html', [htmlParser], cmInstance); + } +} diff --git a/client/modules/IDE/components/Editor/utils.js b/client/modules/IDE/components/Editor/utils.js new file mode 100644 index 0000000000..fb89d57a68 --- /dev/null +++ b/client/modules/IDE/components/Editor/utils.js @@ -0,0 +1,20 @@ +/** Detects what mode the file is based on the name. */ +export default function getFileMode(fileName) { + let mode; + if (fileName.match(/.+\.js$/i)) { + mode = 'javascript'; + } else if (fileName.match(/.+\.css$/i)) { + mode = 'css'; + } else if (fileName.match(/.+\.(html|xml)$/i)) { + mode = 'htmlmixed'; + } else if (fileName.match(/.+\.json$/i)) { + mode = 'application/json'; + } else if (fileName.match(/.+\.(frag|glsl)$/i)) { + mode = 'x-shader/x-fragment'; + } else if (fileName.match(/.+\.(vert|stl|mtl)$/i)) { + mode = 'x-shader/x-vertex'; + } else { + mode = 'text/plain'; + } + return mode; +}