diff --git a/client/modules/IDE/components/Editor.jsx b/client/modules/IDE/components/Editor.jsx
index 585f3772..d4699cf8 100644
--- a/client/modules/IDE/components/Editor.jsx
+++ b/client/modules/IDE/components/Editor.jsx
@@ -9,7 +9,11 @@ import 'codemirror/addon/lint/css-lint';
import 'codemirror/addon/lint/html-lint';
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 { JSHINT } from 'jshint';
import { CSSLint } from 'csslint';
import { HTMLHint } from 'htmlhint';
@@ -20,6 +24,13 @@ import '../../../utils/htmlmixed';
import '../../../utils/p5-javascript';
import Timer from '../components/Timer';
import EditorAccessibility from '../components/EditorAccessibility';
+import {
+ metaKey,
+} from '../../../utils/metaKey';
+
+import search from '../../../utils/codemirror-search';
+
+search(CodeMirror);
const beautifyCSS = beautifyJS.css;
const beautifyHTML = beautifyJS.html;
@@ -51,6 +62,7 @@ class Editor extends React.Component {
fixedGutter: false,
gutters: ['CodeMirror-lint-markers'],
keyMap: 'sublime',
+ highlightSelectionMatches: true, // highlight current search match
lint: {
onUpdateLinting: debounce((annotations) => {
this.props.clearLintMessage();
@@ -72,10 +84,11 @@ class Editor extends React.Component {
});
this._cm.setOption('extraKeys', {
- 'Cmd-Enter': () => null,
- 'Shift-Cmd-Enter': () => null,
- 'Ctrl-Enter': () => null,
- 'Shift-Ctrl-Enter': () => null
+ [`${metaKey}-Enter`]: () => null,
+ [`Shift-${metaKey}-Enter`]: () => null,
+ [`${metaKey}-F`]: 'findPersistent',
+ [`${metaKey}-G`]: 'findNext',
+ [`Shift-${metaKey}-G`]: 'findPrev',
});
this.initializeDocuments(this.props.files);
diff --git a/client/modules/IDE/components/KeyboardShortcutModal.jsx b/client/modules/IDE/components/KeyboardShortcutModal.jsx
index fe2cb5ad..4101a6f4 100644
--- a/client/modules/IDE/components/KeyboardShortcutModal.jsx
+++ b/client/modules/IDE/components/KeyboardShortcutModal.jsx
@@ -1,6 +1,10 @@
import React, { PropTypes } from 'react';
import InlineSVG from 'react-inlinesvg';
+import {
+ metaKeyName,
+} from '../../../utils/metaKey';
+
const exitUrl = require('../../../images/exit.svg');
class KeyboardShortcutModal extends React.Component {
@@ -24,48 +28,67 @@ class KeyboardShortcutModal extends React.Component {
- {this.isMac ? 'Command + S' : 'Control + S'}
+ {metaKeyName} + S
Save
- {this.isMac ? 'Command + [' : 'Control + ['}
+ {metaKeyName} + F
+
+ Find Text
+
+
+
+ {metaKeyName} + G
+
+ Find Next Text Match
+
+
+
+ {metaKeyName} + Shift + G
+
+ Find Previous Text Match
+
+
+
+ {metaKeyName} + [
Indent Code Left
- {this.isMac ? 'Command + ]' : 'Control + ]'}
+ {metaKeyName} + ]
Indent Code Right
- {this.isMac ? 'Command + /' : 'Control + /'}
+ {metaKeyName} + /
Comment Line
- {this.isMac ? 'Command + Enter' : 'Control + Enter'}
+ {metaKeyName} + Enter
+
Start Sketch
- {this.isMac ? 'Command + Shift + Enter' : 'Control + Shift + Enter'}
+ {metaKeyName} + Shift + Enter
Stop Sketch
- {this.isMac ? 'Command + Shift + 1' : 'Control + Shift + 1'}
+ {metaKeyName} + Shift + 1
Toggle Text-based Canvas
- {this.isMac ? 'Command + Shift + 2' : 'Control + Shift + 2'}
+ {metaKeyName} + Shift + 2
Turn Off Text-based Canvas
diff --git a/client/styles/components/_editor.scss b/client/styles/components/_editor.scss
index ad455a00..6b460d9c 100644
--- a/client/styles/components/_editor.scss
+++ b/client/styles/components/_editor.scss
@@ -80,6 +80,164 @@
width: #{48 / $base-font-size}rem;
}
+/*
+ Search dialog
+*/
+
+.CodeMirror-dialog {
+ position: absolute;
+ top: 0;
+ right: 0;
+
+ z-index: 10;
+
+ min-width: 365px;
+
+ font-family: Montserrat, sans-serif;
+
+ padding: #{14 / $base-font-size}rem #{20 / $base-font-size}rem #{14 / $base-font-size}rem #{18 / $base-font-size}rem;
+
+ border-radius: 2px;
+
+ @include themify() {
+ background-color: getThemifyVariable('modal-background-color');
+ box-shadow: 0 12px 12px 0 getThemifyVariable('shadow-color');
+ border: solid 0.5px getThemifyVariable('modal-border-color');
+ }
+}
+
+.CodeMirror-search-title {
+ display: block;
+ margin-bottom: #{12 / $base-font-size}rem;
+
+ font-size: #{21 / $base-font-size}rem;
+ font-weight: bold;
+}
+
+.CodeMirror-search-field {
+ display: block;
+ width: 100%;
+ margin-bottom: #{12 / $base-font-size}rem;
+}
+
+.CodeMirror-search-count {
+ display: block;
+ height: #{20 / $base-font-size}rem;
+ text-align: right;
+}
+
+.CodeMirror-search-actions {
+ display: flex;
+ justify-content: space-between;
+}
+
+/*
+
+*/
+.CodeMirror-search-modifiers {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.CodeMirror-regexp-button,
+.CodeMirror-case-button,
+.CodeMirror-word-button {
+ width: 20px;
+ height: 20px;
+
+ margin-left: #{10 / $base-font-size}rem;
+
+ word-break: keep-all;
+ white-space: nowrap;
+
+ @include themify() {
+ color: getThemifyVariable('inactive-text-color');
+ }
+}
+
+.CodeMirror-regexp-button .label,
+.CodeMirror-case-button .label,
+.CodeMirror-word-button .label {
+ @extend %hidden-element;
+}
+
+[aria-checked="true"] {
+ @include themify() {
+ color: getThemifyVariable('primary-text-color');
+ }
+}
+
+/*
+ Previous / Next buttons
+*/
+
+// Visually hide button text
+.CodeMirror-search-button .label {
+ @extend %hidden-element;
+}
+
+.CodeMirror-search-button {
+ margin-right: #{10 / $base-font-size}rem;
+}
+
+.CodeMirror-search-button::after {
+ display: block;
+ content: ' ';
+
+ width: 14px;
+ height: 14px;
+
+ @include themify() {
+ @extend %icon;
+ }
+
+ background-repeat: no-repeat;
+ background-position: center;
+}
+
+// Previous button
+.CodeMirror-search-button.prev::after {
+ background-image: url(../images/up-arrow.svg)
+}
+
+// Next button
+.CodeMirror-search-button.next::after {
+ background-image: url(../images/down-arrow.svg)
+}
+
+/*
+ Close button
+*/
+.CodeMirror-close-button {
+ position: absolute;
+ top: #{14 / $base-font-size}rem;
+ right: #{18 / $base-font-size}rem;
+
+ display: flex;
+ flex-direction: row;
+}
+
+// Visually hide button text
+.CodeMirror-close-button .label {
+ @extend %hidden-element;
+}
+
+.CodeMirror-close-button:after {
+ display: block;
+ content: ' ';
+
+ width: 16px;
+ height: 16px;
+
+ margin-left: #{8 / $base-font-size}rem;
+
+ @include themify() {
+ @extend %icon;
+ }
+
+ background: transparent url(../images/exit.svg) no-repeat;
+}
+
.editor-holder {
height: calc(100% - #{29 / $base-font-size}rem);
width: 100%;
diff --git a/client/utils/codemirror-search.js b/client/utils/codemirror-search.js
new file mode 100644
index 00000000..340bb74a
--- /dev/null
+++ b/client/utils/codemirror-search.js
@@ -0,0 +1,433 @@
+/* eslint-disable */
+// CodeMirror, copyright (c) by Marijn Haverbeke and others
+// Distributed under an MIT license: http://codemirror.net/LICENSE
+
+// Define search commands. Depends on dialog.js or another
+// implementation of the openDialog method.
+
+// Replace works a little oddly -- it will do the replace on the next
+// Ctrl-G (or whatever is bound to findNext) press. You prevent a
+// replace by making sure the match is no longer selected when hitting
+// Ctrl-G.
+
+export default function(CodeMirror) {
+ "use strict";
+
+ function searchOverlay(query) {
+ return {token: function(stream) {
+ query.lastIndex = stream.pos;
+ var match = query.exec(stream.string);
+ if (match && match.index == stream.pos) {
+ stream.pos += match[0].length || 1;
+ return "searching";
+ } else if (match) {
+ stream.pos = match.index;
+ } else {
+ stream.skipToEnd();
+ }
+ }};
+ }
+
+ function SearchState() {
+ this.posFrom = this.posTo = this.lastQuery = this.query = null;
+ this.overlay = null;
+ this.regexp = false;
+ this.caseInsensitive = true;
+ this.wholeWord = false;
+ }
+
+ function getSearchState(cm) {
+ return cm.state.search || (cm.state.search = new SearchState());
+ }
+
+ function getSearchCursor(cm, query, pos) {
+ return cm.getSearchCursor(query, pos, getSearchState(cm).caseInsensitive);
+ }
+
+ function persistentDialog(cm, text, deflt, onEnter, onKeyDown) {
+ var searchField = document.getElementsByClassName("CodeMirror-search-field")[0];
+ if (!searchField) {
+ cm.openDialog(text, onEnter, {
+ value: deflt,
+ selectValueOnOpen: true,
+ closeOnEnter: false,
+ onClose: function () {
+ clearSearch(cm);
+ },
+ onKeyDown: onKeyDown,
+ closeOnBlur: false
+ });
+
+ searchField = document.getElementsByClassName("CodeMirror-search-field")[0];
+
+ var dialog = document.getElementsByClassName("CodeMirror-dialog")[0];
+ var closeButton = dialog.getElementsByClassName("close")[0];
+
+ var state = getSearchState(cm);
+
+ CodeMirror.on(searchField, "keyup", function (e) {
+ if (e.keyCode !== 13 && searchField.value.length > 1) { // not enter and more than 1 character to search
+ startSearch(cm, getSearchState(cm), searchField.value);
+ }
+ });
+
+ CodeMirror.on(closeButton, "click", function () {
+ clearSearch(cm);
+ dialog.parentNode.removeChild(dialog);
+ cm.focus();
+ });
+
+ var upArrow = dialog.getElementsByClassName("up-arrow")[0];
+ CodeMirror.on(upArrow, "click", function () {
+ CodeMirror.commands.findPrev(cm);
+ searchField.blur();
+ cm.focus();
+ });
+
+ var downArrow = dialog.getElementsByClassName("down-arrow")[0];
+ CodeMirror.on(downArrow, "click", function () {
+ CodeMirror.commands.findNext(cm);
+ searchField.blur();
+ cm.focus();
+ });
+
+ var regexpButton = dialog.getElementsByClassName("CodeMirror-regexp-button")[0];
+ CodeMirror.on(regexpButton, "click", function () {
+ var state = getSearchState(cm);
+ state.regexp = toggle(regexpButton);
+ startSearch(cm, getSearchState(cm), searchField.value);
+ });
+
+ toggle(regexpButton, state.regexp);
+
+ var caseSensitiveButton = dialog.getElementsByClassName("CodeMirror-case-button")[0];
+ CodeMirror.on(caseSensitiveButton, "click", function () {
+ var state = getSearchState(cm);
+ state.caseInsensitive = !toggle(caseSensitiveButton);
+ startSearch(cm, getSearchState(cm), searchField.value);
+ });
+
+ toggle(caseSensitiveButton, !state.caseInsensitive);
+
+ var wholeWordButton = dialog.getElementsByClassName("CodeMirror-word-button")[0];
+ CodeMirror.on(wholeWordButton, "click", function () {
+ var state = getSearchState(cm);
+ state.wholeWord = toggle(wholeWordButton);
+ startSearch(cm, getSearchState(cm), searchField.value);
+ });
+
+ toggle(wholeWordButton, state.wholeWord);
+
+ function toggle(el, initialState) {
+ var currentState, nextState;
+
+ if (initialState == null) {
+ currentState = el.getAttribute('aria-checked') === 'true';
+ nextState = !currentState;
+ } else {
+ nextState = initialState;
+ }
+
+ el.setAttribute('aria-checked', nextState);
+ return nextState;
+ }
+ } else {
+ searchField.focus();
+ }
+ }
+
+ function dialog(cm, text, shortText, deflt, f) {
+ if (cm.openDialog) cm.openDialog(text, f, {value: deflt, selectValueOnOpen: true});
+ else f(prompt(shortText, deflt));
+ }
+
+ var lastSelectedIndex = 0;
+ function confirmDialog(cm, text, shortText, fs) {
+ if (cm.openConfirm) cm.openConfirm(text, fs);
+ else if (confirm(shortText)) fs[0]();
+
+ var dialog = document.getElementsByClassName("CodeMirror-dialog")[0];
+ var buttons = dialog.getElementsByTagName("button");
+ buttons[lastSelectedIndex].focus();
+ for (var i = 0; i < buttons.length; i += 1) {
+ (function (index) {
+ var button = buttons[index];
+ button.addEventListener("focus", function (e) {
+ lastSelectedIndex = index === buttons.length - 1 ? 0 : index;
+ });
+ button.addEventListener("keyup", function (e) {
+ if (e.keyCode === 37) { // arrow left
+ var prevButton = index === 0 ? buttons.length - 1 : index - 1;
+ buttons[prevButton].focus();
+ }
+ if (e.keyCode === 39) { // arrow right
+ var nextButton = index === buttons.length - 1 ? 0 : index + 1;
+ buttons[nextButton].focus();
+ }
+ if (e.keyCode === 27) { // esc
+ cm.focus();
+ }
+ });
+ button.addEventListener("click", function () {
+ if (index === buttons.length - 1) { // "done"
+ lastSelectedIndex = 0;
+ }
+ })
+ })(i);
+ }
+ }
+
+ function parseString(string) {
+ return string.replace(/\\(.)/g, function(_, ch) {
+ if (ch == "n") return "\n"
+ if (ch == "r") return "\r"
+ return ch
+ })
+ }
+
+ /*
+ Parses the query text and state and returns
+ a RegExp ready for searching
+ */
+ function parseQuery(query, state) {
+ var emptyQuery = 'x^'; // matches nothing
+
+ if (query === '') { // empty string matches nothing
+ query = emptyQuery;
+ } else {
+ if (state.regexp === false) {
+ query = parseString(query);
+ query = query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
+ }
+
+ if (state.wholeWord) {
+ query += '\\b';
+ }
+ }
+
+ var regexp;
+
+ try {
+ regexp = new RegExp(query, state.caseInsensitive ? "gi" : "g");
+ } catch (e) {
+ regexp = new RegExp(emptyQuery, 'g');
+ }
+
+ // If the resulting regexp will match everything, do not use it
+ if (regexp.test('')) {
+ return new RegExp(emptyQuery, 'g');
+ }
+
+ return regexp;
+ }
+
+ var queryDialog = `
+ Find
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ function startSearch(cm, state, originalQuery) {
+ state.queryText = originalQuery;
+ state.query = parseQuery(originalQuery, state);
+
+ cm.removeOverlay(state.overlay, state.caseInsensitive);
+ state.overlay = searchOverlay(state.query);
+ cm.addOverlay(state.overlay);
+ if (cm.showMatchesOnScrollbar) {
+ if (state.annotate) { state.annotate.clear(); state.annotate = null; }
+ state.annotate = cm.showMatchesOnScrollbar(state.query, state.caseInsensitive);
+ }
+ }
+
+ function doSearch(cm, rev, persistent, immediate, ignoreQuery) {
+ var state = getSearchState(cm);
+ if (!ignoreQuery && state.query) return findNext(cm, rev);
+ var q = cm.getSelection() || state.lastQuery;
+ if (persistent && cm.openDialog) {
+ var hiding = null
+ var searchNext = function(query, event) {
+ CodeMirror.e_stop(event);
+ if (!query) return;
+ if (query != state.queryText) {
+ startSearch(cm, state, query);
+ state.posFrom = state.posTo = cm.getCursor();
+ }
+ if (hiding) hiding.style.opacity = 1
+ findNext(cm, event.shiftKey, function(_, to) {
+ var dialog
+ if (to.line < 3 && document.querySelector &&
+ (dialog = cm.display.wrapper.querySelector(".CodeMirror-dialog")) &&
+ dialog.getBoundingClientRect().bottom - 4 > cm.cursorCoords(to, "window").top)
+ (hiding = dialog).style.opacity = .4
+ })
+ };
+ persistentDialog(cm, queryDialog, q, searchNext, function(event, query) {
+ var keyName = CodeMirror.keyName(event)
+ var cmd = CodeMirror.keyMap[cm.getOption("keyMap")][keyName]
+ if (!cmd) cmd = cm.getOption('extraKeys')[keyName]
+ if (cmd == "findNext" || cmd == "findPrev" ||
+ cmd == "findPersistentNext" || cmd == "findPersistentPrev") {
+ CodeMirror.e_stop(event);
+ startSearch(cm, getSearchState(cm), query);
+ cm.execCommand(cmd);
+ } else if (cmd == "find" || cmd == "findPersistent") {
+ CodeMirror.e_stop(event);
+ searchNext(query, event);
+ }
+ });
+ if (immediate && q) {
+ startSearch(cm, state, q);
+ findNext(cm, rev);
+ }
+ } else {
+ dialog(cm, queryDialog, "Search for:", q, function(query) {
+ if (query && !state.query) cm.operation(function() {
+ startSearch(cm, state, query);
+ state.posFrom = state.posTo = cm.getCursor();
+ findNext(cm, rev);
+ });
+ });
+ }
+ }
+
+ function findNext(cm, rev, callback) {cm.operation(function() {
+ var state = getSearchState(cm);
+ var cursor = getSearchCursor(cm, state.query, rev ? state.posFrom : state.posTo);
+ if (!cursor.find(rev)) {
+ cursor = getSearchCursor(cm, state.query, rev ? CodeMirror.Pos(cm.lastLine()) : CodeMirror.Pos(cm.firstLine(), 0));
+ if (!cursor.find(rev)) return;
+ }
+ cm.setSelection(cursor.from(), cursor.to());
+ cm.scrollIntoView({from: cursor.from(), to: cursor.to()}, 60);
+ state.posFrom = cursor.from(); state.posTo = cursor.to();
+ if (callback) callback(cursor.from(), cursor.to())
+ });}
+
+ function clearSearch(cm) {cm.operation(function() {
+ var state = getSearchState(cm);
+ state.lastQuery = state.queryText;
+ if (!state.query) return;
+ state.query = state.queryText = null;
+ cm.removeOverlay(state.overlay);
+ if (state.annotate) { state.annotate.clear(); state.annotate = null; }
+ });}
+
+ var replaceQueryDialog =
+ '';
+
+ var replacementQueryDialog = 'With: ';
+ var doReplaceConfirm = "Replace? ";
+
+ function replaceAll(cm, query, text) {
+ cm.operation(function() {
+ for (var cursor = getSearchCursor(cm, query); cursor.findNext();) {
+ if (typeof query != "string") {
+ var match = cm.getRange(cursor.from(), cursor.to()).match(query);
+ cursor.replace(text.replace(/\$(\d)/g, function(_, i) {return match[i];}));
+ } else cursor.replace(text);
+ }
+ });
+ }
+
+ // TODO: This will need updating if replace is implemented
+ function replace(cm, all) {
+ var prevDialog = document.getElementsByClassName("CodeMirror-dialog")[0];
+ if (prevDialog) {
+ clearSearch(cm);
+ prevDialog.parentNode.removeChild(prevDialog);
+ cm.focus();
+ }
+ if (cm.getOption("readOnly")) return;
+ var query = cm.getSelection() || getSearchState(cm).lastQuery;
+ var dialogText = all ? "Replace all:" : "Replace:"
+ dialog(cm, dialogText + replaceQueryDialog, dialogText, query, function(query) {
+ if (!query) return;
+ query = parseQuery(query);
+ dialog(cm, replacementQueryDialog, "Replace with:", "", function(text) {
+ text = parseString(text)
+ if (all) {
+ replaceAll(cm, query, text)
+ } else {
+ clearSearch(cm);
+ var cursor = getSearchCursor(cm, query, cm.getCursor("from"));
+ var advance = function() {
+ var start = cursor.from(), match;
+ if (!(match = cursor.findNext())) {
+ cursor = getSearchCursor(cm, query);
+ if (!(match = cursor.findNext()) ||
+ (start && cursor.from().line == start.line && cursor.from().ch == start.ch)) return;
+ }
+ cm.setSelection(cursor.from(), cursor.to());
+ cm.scrollIntoView({from: cursor.from(), to: cursor.to()}, 60);
+ confirmDialog(cm, doReplaceConfirm, "Replace?",
+ [function() {doReplace(match);}, advance,
+ function() {replaceAll(cm, query, text)}]);
+ };
+ var doReplace = function(match) {
+ cursor.replace(typeof query == "string" ? text :
+ text.replace(/\$(\d)/g, function(_, i) {return match[i];}));
+ advance();
+ };
+ advance();
+ }
+ });
+ });
+ }
+
+ CodeMirror.commands.find = function(cm) {doSearch(cm);};
+ CodeMirror.commands.findPersistent = function(cm) { doSearch(cm, false, true, false, true);};
+ CodeMirror.commands.findPersistentNext = function(cm) {doSearch(cm, false, true, true);};
+ CodeMirror.commands.findPersistentPrev = function(cm) {doSearch(cm, true, true, true);};
+ CodeMirror.commands.findNext = doSearch;
+ CodeMirror.commands.findPrev = function(cm) {doSearch(cm, true);};
+ CodeMirror.commands.clearSearch = clearSearch;
+ // CodeMirror.commands.replace = replace;
+ // CodeMirror.commands.replaceAll = function(cm) {replace(cm, true);};
+};
diff --git a/client/utils/metaKey.js b/client/utils/metaKey.js
new file mode 100644
index 00000000..e88052e1
--- /dev/null
+++ b/client/utils/metaKey.js
@@ -0,0 +1,16 @@
+const metaKey = (() => {
+ if (navigator != null && navigator.platform != null) {
+ return /^MAC/i.test(navigator.platform) ?
+ 'Cmd' :
+ 'Ctrl';
+ }
+
+ return 'Ctrl';
+})();
+
+const metaKeyName = metaKey === 'Cmd' ? 'Command' : 'Control';
+
+export {
+ metaKey,
+ metaKeyName,
+};