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, +};