diff --git a/client/modules/IDE/components/Editor/codemirror.js b/client/modules/IDE/components/Editor/codemirror.js new file mode 100644 index 0000000000..8b42e48b6e --- /dev/null +++ b/client/modules/IDE/components/Editor/codemirror.js @@ -0,0 +1,203 @@ +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 { metaKey } from '../../../../utils/metaKey'; +import { showHint } from './hinter'; +import tidyCode from './tidier'; + +const INDENTATION_AMOUNT = 2; + +emmet(CodeMirror); + +function setupCodeMirrorHooks( + cmInstance, + { + setUnsavedChanges, + hideRuntimeErrorWarning, + updateFileContent, + file, + autorefresh, + isPlaying, + clearConsole, + startSketch, + autocompleteHinter, + fontSize + }, + updateLineNumber +) { + cmInstance.on( + 'change', + debounce(() => { + setUnsavedChanges(true); + hideRuntimeErrorWarning(); + updateFileContent(file.id, cmInstance.getValue()); + if (autorefresh && isPlaying) { + clearConsole(); + startSketch(); + } + }, 1000) + ); + + cmInstance.on('keyup', () => { + const lineNumber = parseInt(cmInstance.getCursor().line + 1, 10); + updateLineNumber(lineNumber); + }); + + cmInstance.on('keydown', (_cm, e) => { + // Show hint + const mode = cmInstance.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.listSelections(); + + if (selections.length > 1) { + const firstPos = selections[0].head || selections[0].anchor; + cmInstance.setSelection(firstPos); + cmInstance.scrollIntoView(firstPos); + } else { + cmInstance.getInputField().blur(); + } + } + }); + + cmInstance.getWrapperElement().style['font-size'] = `${fontSize}px`; +} + +export default function setupCodeMirror( + container, + { + theme, + lineNumbers, + linewrap, + autocloseBracketsQuotes, + setUnsavedChanges, + hideRuntimeErrorWarning, + updateFileContent, + file, + autorefresh, + isPlaying, + clearConsole, + startSketch, + autocompleteHinter, + fontSize + }, + onUpdateLinting, + docs, + updateLineNumber +) { + const cm = 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 cm.options.lint.options.errors; + + const replaceCommand = + metaKey === 'Ctrl' ? `${metaKey}-H` : `${metaKey}-Option-F`; + cm.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', + [`${metaKey}-Enter`]: () => null, + [`Shift-${metaKey}-Enter`]: () => null, + [`${metaKey}-F`]: 'findPersistent', + [`Shift-${metaKey}-F`]: () => tidyCode(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`]: (metaCm, event) => + metaCm.state.colorpicker.popup_color_picker({ length: 0 }), + [`${metaKey}-.`]: 'toggleComment' // Note: most adblockers use the shortcut ctrl+. + }); + + setupCodeMirrorHooks( + cm, + { + setUnsavedChanges, + hideRuntimeErrorWarning, + updateFileContent, + file, + autorefresh, + isPlaying, + clearConsole, + startSketch, + autocompleteHinter, + fontSize + }, + updateLineNumber + ); + + cm.swapDoc(docs[file.id]); + + return cm; +} diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 9eac6bb738..7e00bc345e 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -1,34 +1,10 @@ // TODO: convert to functional component import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import CodeMirror from 'codemirror'; -import emmet from '@emmetio/codemirror-plugin'; 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 +13,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,223 +37,209 @@ import { EditorContainer, EditorHolder } from './MobileEditor'; import { FolderIcon } from '../../../../common/icons'; import IconButton from '../../../../common/IconButton'; -import { showHint, hideHinter } from './hinter'; +import { hideHinter } from './hinter'; import getFileMode from './utils'; import tidyCode from './tidier'; +import setupCodeMirror from './codemirror'; +import usePrevious from '../../../../utils/usePrevious'; + +function Editor({ + provideController, + files, + file, + theme, + linewrap, + lineNumbers, + closeProjectOptions, + setSelectedFile, + unsavedChanges, + 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 cm = useRef(); + const beep = useRef(); + const docs = useRef(); + const previous = usePrevious({ file, unsavedChanges, consoleEvents }); + + const updateLintingMessageAccessibility = debounce((annotations) => { + clearLintMessage(); + annotations.forEach((x) => { + if (x.from.line > -1) { + updateLintMessage(x.severity, x.from.line + 1, x.message); + } + }); + if (lintMessages.length > 0 && lintWarning) { + beep.play(); + } + }, 2000); -emmet(CodeMirror); + const getContent = () => { + const content = cm.current.getValue(); + const updatedFile = Object.assign({}, file, { content }); + return updatedFile; + }; -const INDENTATION_AMOUNT = 2; + const showFind = () => { + cm.current.execCommand('findPersistent'); + }; -class Editor extends React.Component { - constructor(props) { - super(props); - this.state = { - currentLine: 1 - }; - this._cm = null; + const showReplace = () => { + cm.current.execCommand('replace'); + }; - 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' + const initializeDocuments = () => { + docs.current = {}; + files.forEach((currentFile) => { + if (currentFile.name !== 'root') { + docs.current[currentFile.id] = CodeMirror.Doc( + currentFile.content, + getFileMode(currentFile.name) + ); } }); + }; - 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)); - } + // Component did mount + const onContainerMounted = useCallback((containerNode) => { + beep.current = new Audio(beepUrl); + + // We need to do this first so docs exists. + initializeDocuments(); + + cm.current = setupCodeMirror( + containerNode, + { + theme, + lineNumbers, + linewrap, + autocloseBracketsQuotes, + setUnsavedChanges, + hideRuntimeErrorWarning, + updateFileContent, + file, + autorefresh, + isPlaying, + clearConsole, + startSketch, + autocompleteHinter, + fontSize }, - Enter: 'emmetInsertLineBreak', - Esc: 'emmetResetAbbreviation', - [`${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) + updateLintingMessageAccessibility, + docs.current, + setCurrentLine ); - - if (this._cm) { - this._cm.on('keyup', this.handleKeyUp); - } - - 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); - } - if (e.key === 'Escape') { - e.preventDefault(); - this._cm.getInputField().blur(); - } + }, []); + + // Component did mount + useEffect(() => { + provideController({ + tidyCode: () => tidyCode(cm.current), + showFind, + showReplace, + getContent }); - this._cm.getWrapperElement().style[ - 'font-size' - ] = `${this.props.fontSize}px`; + return () => { + provideController(null); + // if (cm.current) { + // cm.current.off('keyup', this.handleKeyUp); + // } + }; + }, []); - this.props.provideController({ - tidyCode: () => tidyCode(this._cm), - showFind: this.showFind, - showReplace: this.showReplace, - getContent: this.getContent - }); - } + useEffect(() => { + initializeDocuments(); + }, [files]); - 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); + useEffect(() => { + 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 + }; + cm.current.setOption('emmet', emmetConfig); } - if (this.props.files.length !== nextProps.files.length) { - this.initializeDocuments(nextProps.files); + const oldDoc = cm.current.swapDoc(docs.current[file.id]); + if (previous?.file) { + docs.current[previous.file.id] = oldDoc; } - } - - 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(); + cm.current.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 (!previous?.unsavedChanges) { + setTimeout(() => setUnsavedChanges(false), 400); } - 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); - } + + for (let i = 0; i < cm.current.lineCount(); i += 1) { + cm.current.removeLineClass(i, 'background', 'line-runtime-error'); } - if (this.props.runtimeErrorWarningVisible) { - if (this.props.consoleEvents.length !== prevProps.consoleEvents.length) { - this.props.consoleEvents.forEach((consoleEvent) => { + // I think we only need to re-provide this if the content changes? idk + // TODO(connie) - Revisit the logic here + provideController({ + tidyCode: () => tidyCode(cm.current), + showFind, + showReplace, + getContent + }); + }, [file]); + + /** Move this to CM file */ + useEffect(() => { + cm.current.getWrapperElement().style['font-size'] = `${fontSize}px`; + }, [fontSize]); + useEffect(() => { + cm.current.setOption('lineWrapping', linewrap); + }, [linewrap]); + useEffect(() => { + cm.current.setOption('theme', `p5-${theme}`); + }, [theme]); + useEffect(() => { + cm.current.setOption('lineNumbers', lineNumbers); + }, [lineNumbers]); + useEffect(() => { + cm.current.setOption('autoCloseBrackets', autocloseBracketsQuotes); + }, [autocloseBracketsQuotes]); + /** end move to CM file */ + + useEffect(() => { + // close the hinter window once the preference is turned off + if (!autocompleteHinter) hideHinter(cm.current); + }, [autocompleteHinter]); + + // TODO: Should this be watching more deps? + useEffect(() => { + if (runtimeErrorWarningVisible) { + if (previous && consoleEvents.length !== previous.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) => { - this.props.expandConsole(); + expandConsole(); const line = stackLines.find( (l) => l.fileName && l.fileName.startsWith('/') ); @@ -286,11 +247,11 @@ class Editor extends React.Component { const fileNameArray = line.fileName.split('/'); const fileName = fileNameArray.slice(-1)[0]; const filePath = fileNameArray.slice(0, -1).join('/'); - const fileWithError = this.props.files.find( + const fileWithError = files.find( (f) => f.name === fileName && f.filePath === filePath ); - this.props.setSelectedFile(fileWithError.id); - this._cm.addLineClass( + setSelectedFile(fileWithError.id); + cm.current.addLineClass( line.lineNumber - 1, 'background', 'line-runtime-error' @@ -299,161 +260,89 @@ class Editor extends React.Component { } }); } else { - for (let i = 0; i < this._cm.lineCount(); i += 1) { - this._cm.removeLineClass(i, 'background', 'line-runtime-error'); + for (let i = 0; i < cm.current.lineCount(); i += 1) { + cm.current.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; - - return ( - - {(matches) => - matches ? ( -
-
- - -
- - {this.props.file.name} - - - -
+ }, [consoleEvents, runtimeErrorWarningVisible]); + + 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} + + +
+
+ { this.codemirrorContainer = element; }} - className={editorHolderClass} /> - {this.props.file.url ? ( - + {file.url ? ( + ) : null}
- ) : ( - -
- - - {this.props.file.name} - - -
-
- { - this.codemirrorContainer = element; - }} - /> - {this.props.file.url ? ( - - ) : null} - -
-
- ) - } -
- ); - } + + ) + } + + ); } Editor.propTypes = { diff --git a/client/modules/IDE/components/FileNode.jsx b/client/modules/IDE/components/FileNode.jsx index e589fb3cff..3c84d31196 100644 --- a/client/modules/IDE/components/FileNode.jsx +++ b/client/modules/IDE/components/FileNode.jsx @@ -292,6 +292,7 @@ const FileNode = ({ ref={fileOptionsRef} tabIndex="0" onClick={toggleFileOptions} + onBlur={() => setTimeout(hideFileOptions, 200)} >