diff --git a/.eslintignore b/.eslintignore index cb233970d6..8ee8cc947c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -49,7 +49,6 @@ common/lib/xmodule/xmodule/js/src/conditional/display.js common/lib/xmodule/xmodule/js/src/discussion/display.js common/lib/xmodule/xmodule/js/src/html/display.js common/lib/xmodule/xmodule/js/src/html/edit.js -common/lib/xmodule/xmodule/js/src/problem/edit.js common/lib/xmodule/xmodule/js/src/raw/edit/json.js common/lib/xmodule/xmodule/js/src/raw/edit/metadata-only.js common/lib/xmodule/xmodule/js/src/raw/edit/xml.js diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 118e207e99..07aca64aa3 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -154,7 +154,7 @@ class CapaDescriptor(CapaFields, RawDescriptor): show_in_read_only_mode = True template_dir_name = 'problem' mako_template = "widgets/problem-edit.html" - js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]} + js = {'js': [resource_string(__name__, 'js/src/problem/edit.js')]} js_module_name = "MarkdownEditingDescriptor" has_author_view = True css = { diff --git a/common/lib/xmodule/xmodule/js/src/problem/.gitignore b/common/lib/xmodule/xmodule/js/src/problem/.gitignore new file mode 100644 index 0000000000..db7cd1fec1 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/problem/.gitignore @@ -0,0 +1 @@ +!edit.js diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee deleted file mode 100644 index dd8af46155..0000000000 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ /dev/null @@ -1,631 +0,0 @@ -# Coffeescript markdown support. -# Most of the functionality is in the markdownToXml function, -# which in fact is regular javascript within backticks. - -class @MarkdownEditingDescriptor extends XModule.Descriptor - # TODO really, these templates should come from or also feed the cheatsheet - @multipleChoiceTemplate : "( ) #{gettext 'incorrect'}\n( ) #{gettext 'incorrect'}\n(x) #{gettext 'correct'}\n" - @checkboxChoiceTemplate: "[x] #{gettext 'correct'}\n[ ] incorrect\n[x] correct\n" - @stringInputTemplate: "= #{gettext 'answer'}\n" - @numberInputTemplate: "= #{gettext 'answer'} +- 0.001%\n" - @selectTemplate: "[[#{gettext 'incorrect'}, (#{gettext 'correct'}), #{gettext 'incorrect'}]]\n" - @headerTemplate: "#{gettext 'Header'}\n=====\n" - @explanationTemplate: "[explanation]\n#{gettext 'Short explanation'}\n[explanation]\n" - - constructor: (element) -> - @element = element - - if $(".markdown-box", @element).length != 0 - @markdown_editor = CodeMirror.fromTextArea($(".markdown-box", element)[0], { - lineWrapping: true - mode: null - }) - @setCurrentEditor(@markdown_editor) - # Add listeners for toolbar buttons (only present for markdown editor) - @element.on('click', '.xml-tab', @onShowXMLButton) - @element.on('click', '.format-buttons button', @onToolbarButton) - @element.on('click', '.cheatsheet-toggle', @toggleCheatsheet) - # Hide the XML text area - $(@element.find('.xml-box')).hide() - else - @createXMLEditor() - - ### - Creates the XML Editor and sets it as the current editor. If text is passed in, - it will replace the text present in the HTML template. - - text: optional argument to override the text passed in via the HTML template - ### - createXMLEditor: (text) -> - @xml_editor = CodeMirror.fromTextArea($(".xml-box", @element)[0], { - mode: "xml" - lineNumbers: true - lineWrapping: true - }) - if text - @xml_editor.setValue(text) - @setCurrentEditor(@xml_editor) - $(@xml_editor.getWrapperElement()).toggleClass("CodeMirror-advanced"); - # Need to refresh to get line numbers to display properly. - @xml_editor.refresh() - - ### - User has clicked to show the XML editor. Before XML editor is swapped in, - the user will need to confirm the one-way conversion. - ### - onShowXMLButton: (e) => - e.preventDefault(); - if @cheatsheet && @cheatsheet.hasClass('shown') - @cheatsheet.toggleClass('shown') - @toggleCheatsheetVisibility() - if @confirmConversionToXml() - @createXMLEditor(MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue())) - # Put cursor position to 0. - @xml_editor.setCursor(0) - # Hide markdown-specific toolbar buttons - $(@element.find('.editor-bar')).hide() - - ### - Have the user confirm the one-way conversion to XML. - Returns true if the user clicked OK, else false. - ### - confirmConversionToXml: -> - # TODO: use something besides a JavaScript confirm dialog? - return confirm(gettext "If you use the Advanced Editor, this problem will be converted to XML and you will not be able to return to the Simple Editor Interface.\n\nProceed to the Advanced Editor and convert this problem to XML?") - - ### - Event listener for toolbar buttons (only possible when markdown editor is visible). - ### - onToolbarButton: (e) => - e.preventDefault(); - selection = @markdown_editor.getSelection() - revisedSelection = null - switch $(e.currentTarget).attr('class') - when "multiple-choice-button" then revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice(selection) - when "string-button" then revisedSelection = MarkdownEditingDescriptor.insertStringInput(selection) - when "number-button" then revisedSelection = MarkdownEditingDescriptor.insertNumberInput(selection) - when "checks-button" then revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice(selection) - when "dropdown-button" then revisedSelection = MarkdownEditingDescriptor.insertSelect(selection) - when "header-button" then revisedSelection = MarkdownEditingDescriptor.insertHeader(selection) - when "explanation-button" then revisedSelection = MarkdownEditingDescriptor.insertExplanation(selection) - else # ignore click - - if revisedSelection != null - @markdown_editor.replaceSelection(revisedSelection) - @markdown_editor.focus() - - ### - Event listener for toggling cheatsheet (only possible when markdown editor is visible). - ### - toggleCheatsheet: (e) => - e.preventDefault(); - if !$(@markdown_editor.getWrapperElement()).find('.simple-editor-cheatsheet')[0] - @cheatsheet = $($('#simple-editor-cheatsheet').html()) - $(@markdown_editor.getWrapperElement()).append(@cheatsheet) - - @toggleCheatsheetVisibility() - - setTimeout (=> @cheatsheet.toggleClass('shown')), 10 - - - ### - Function to toggle cheatsheet visibility. - ### - toggleCheatsheetVisibility: () => - $('.modal-content').toggleClass('cheatsheet-is-shown') - - ### - Stores the current editor and hides the one that is not displayed. - ### - setCurrentEditor: (editor) -> - if @current_editor - $(@current_editor.getWrapperElement()).hide() - @current_editor = editor - $(@current_editor.getWrapperElement()).show() - $(@current_editor).focus(); - - ### - Called when save is called. Listeners are unregistered because editing the block again will - result in a new instance of the descriptor. Note that this is NOT the case for cancel-- - when cancel is called the instance of the descriptor is reused if edit is selected again. - ### - save: -> - @element.off('click', '.xml-tab', @changeEditor) - @element.off('click', '.format-buttons button', @onToolbarButton) - @element.off('click', '.cheatsheet-toggle', @toggleCheatsheet) - if @current_editor == @markdown_editor - { - data: MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue()) - metadata: markdown: @markdown_editor.getValue() - } - else - { - data: @xml_editor.getValue() - nullout: ['markdown'] - } - - @insertMultipleChoice: (selectedText) -> - return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '(', ')', MarkdownEditingDescriptor.multipleChoiceTemplate) - - @insertCheckboxChoice: (selectedText) -> - return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '[', ']', MarkdownEditingDescriptor.checkboxChoiceTemplate) - - @insertGenericChoice: (selectedText, choiceStart, choiceEnd, template) -> - if selectedText.length > 0 - # Replace adjacent newlines with a single newline, strip any trailing newline - cleanSelectedText = selectedText.replace(/\n+/g, '\n').replace(/\n$/,'') - lines = cleanSelectedText.split('\n') - revisedLines = '' - for line in lines - revisedLines += choiceStart - # a stand alone x before other text implies that this option is "correct" - if /^\s*x\s+(\S)/i.test(line) - # Remove the x and any initial whitespace as long as there's more text on the line - line = line.replace(/^\s*x\s+(\S)/i, '$1') - revisedLines += 'x' - else - revisedLines += ' ' - revisedLines += choiceEnd + ' ' + line + '\n' - return revisedLines - else - return template - - @insertStringInput: (selectedText) -> - return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', MarkdownEditingDescriptor.stringInputTemplate) - - @insertNumberInput: (selectedText) -> - return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', MarkdownEditingDescriptor.numberInputTemplate) - - @insertSelect: (selectedText) -> - return MarkdownEditingDescriptor.insertGenericInput(selectedText, '[[', ']]', MarkdownEditingDescriptor.selectTemplate) - - @insertHeader: (selectedText) -> - return MarkdownEditingDescriptor.insertGenericInput(selectedText, '', '\n====\n', MarkdownEditingDescriptor.headerTemplate) - - @insertExplanation: (selectedText) -> - return MarkdownEditingDescriptor.insertGenericInput(selectedText, '[explanation]\n', '\n[explanation]', MarkdownEditingDescriptor.explanationTemplate) - - @insertGenericInput: (selectedText, lineStart, lineEnd, template) -> - if selectedText.length > 0 - # TODO: should this insert a newline afterwards? - return lineStart + selectedText + lineEnd - else - return template - - @markdownToXml: (markdown)-> - # it will contain ... tags - demandHintTags = []; - toXml = `function (markdown) { - var xml = markdown, - i, splits, makeParagraph; - var responseTypes = [ - 'optionresponse', 'multiplechoiceresponse', 'stringresponse', 'numericalresponse', 'choiceresponse' - ]; - - // fix DOS \r\n line endings to look like \n - xml = xml.replace(/\r\n/g, '\n'); - - // replace headers - xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '

$1

'); - xml = xml.replace(/\n^\=\=+$/gm, ''); - - // extract question and description(optional) - // >>question||description<< converts to - // description - xml = xml.replace(/>>([^]+?)<' + '\n'; - - // don't add empty tag - if (result.length === 1 || !result[1]) { - return label; - } - return label + '' + result[1] + '\n' - }) - - // Pull out demand hints, || a hint || - var demandhints = ''; - xml = xml.replace(/(^\s*\|\|.*?\|\|\s*$\n?)+/gm, function(match) { // $\n - var options = match.split('\n'); - for (i = 0; i < options.length; i += 1) { - var inner = /\s*\|\|(.*?)\|\|/.exec(options[i]); - if (inner) { - //safe-lint: disable=javascript-concat-html - demandhints += ' ' + inner[1].trim() + '\n'; - } - } - return ''; - }); - - // replace \n+whitespace within extended hint {{ .. }}, by a space, so the whole - // hint sits on one line. - // This is the one instance of {{ ... }} matching that permits \n - xml = xml.replace(/{{(.|\n)*?}}/gm, function(match) { - return match.replace(/\r?\n( |\t)*/g, ' '); - }); - - // Function used in many places to extract {{ label:: a hint }}. - // Returns a little hash with various parts of the hint: - // hint: the hint or empty, nothint: the rest - // labelassign: javascript assignment of label attribute, or empty - extractHint = function(text, detectParens) { - var curly = /\s*{{(.*?)}}/.exec(text); - var hint = ''; - var label = ''; - var parens = false; - var labelassign = ''; - if (curly) { - text = text.replace(curly[0], ''); - hint = curly[1].trim(); - var labelmatch = /^(.*?)::/.exec(hint); - if (labelmatch) { - hint = hint.replace(labelmatch[0], '').trim(); - label = labelmatch[1].trim(); - labelassign = ' label="' + label + '"'; - } - } - if (detectParens) { - if (text.length >= 2 && text[0] == '(' && text[text.length-1] == ')') { - text = text.substring(1, text.length-1) - parens = true; - } - } - return {'nothint': text, 'hint': hint, 'label': label, 'parens': parens, 'labelassign': labelassign}; - } - - - // replace selects - // [[ a, b, (c) ]] - // [[ - // a - // b - // (c) - // ]] - // - // - // \n\n'; - } - - // new style [[ many-lines ]] - var lines = group1.split('\n'); - var optionlines = '' - for (i = 0; i < lines.length; i++) { - var line = lines[i].trim(); - if (line.length > 0) { - var textHint = extractHint(line, true); - var correctstr = ' correct="' + (textHint.parens?'True':'False') + '"'; - var hintstr = ''; - if (textHint.hint) { - var label = textHint.label; - if (label) { - label = ' label="' + label + '"'; - } - hintstr = ' ' + textHint.hint + ''; - } - optionlines += ' ' + textHint.nothint + hintstr + '\n' - } - } - return '\n\n \n' + optionlines + ' \n\n\n'; - }); - - //_____________________________________________________________________ - // - // multiple choice questions - // - xml = xml.replace(/(^\s*\(.{0,3}\).*?$\n*)+/gm, function(match, p) { - var choices = ''; - var shuffle = false; - var options = match.split('\n'); - for(var i = 0; i < options.length; i++) { - options[i] = options[i].trim(); // trim off leading/trailing whitespace - if(options[i].length > 0) { - var value = options[i].split(/^\s*\(.{0,3}\)\s*/)[1]; - var inparens = /^\s*\((.{0,3})\)\s*/.exec(options[i])[1]; - var correct = /x/i.test(inparens); - var fixed = ''; - if(/@/.test(inparens)) { - fixed = ' fixed="true"'; - } - if(/!/.test(inparens)) { - shuffle = true; - } - - var hint = extractHint(value); - if (hint.hint) { - value = hint.nothint; - value = value + ' ' + hint.hint + ''; - } - choices += ' ' + value + '\n'; - } - } - var result = '\n'; - if(shuffle) { - result += ' \n'; - } else { - result += ' \n'; - } - result += choices; - result += ' \n'; - result += '\n\n'; - return result; - }); - - // group check answers - // [.] with {{...}} lines mixed in - xml = xml.replace(/(^\s*((\[.?\])|({{.*?}})).*?$\n*)+/gm, function(match) { - var groupString = '\n', - options, value, correct; - - groupString += ' \n'; - options = match.split('\n'); - - endHints = ''; // save these up to emit at the end - - for (i = 0; i < options.length; i += 1) { - if(options[i].trim().length > 0) { - // detect the {{ ((A*B)) ...}} case first - // emits: AB hint - - var abhint = /^\s*{{\s*\(\((.*?)\)\)(.*?)}}/.exec(options[i]); - if (abhint) { - // lone case of hint text processing outside of extractHint, since syntax here is unique - var hintbody = abhint[2]; - hintbody = hintbody.replace('&lf;', '\n').trim() - endHints += ' ' + hintbody + '\n'; - continue; // bail - } - - value = options[i].split(/^\s*\[.?\]\s*/)[1]; - correct = /^\s*\[x\]/i.test(options[i]); - hints = ''; - // {{ selected: You’re right that apple is a fruit. }, {unselected: Remember that apple is also a fruit.}} - var hint = extractHint(value); - if (hint.hint) { - var inner = '{' + hint.hint + '}'; // parsing is easier if we put outer { } back - var select = /{\s*(s|selected):((.|\n)*?)}/i.exec(inner); // include \n since we are downstream of extractHint() - // checkbox choicehints get their own line, since there can be two of them - // You’re right that apple is a fruit. - if (select) { - hints += '\n ' + select[2].trim() + ''; - } - var select = /{\s*(u|unselected):((.|\n)*?)}/i.exec(inner); - if (select) { - hints += '\n ' + select[2].trim() + ''; - } - - // Blank out the original text only if the specific "selected" syntax is found - // That way, if the user types it wrong, at least they can see it's not processed. - if (hints) { - value = hint.nothint; - } - } - groupString += ' ' + value + hints +'\n'; - } - } - - groupString += endHints; - groupString += ' \n'; - groupString += '\n\n'; - - return groupString; - }); - - - // replace string and numerical, numericalresponse, stringresponse - // A fine example of the function-composition programming style. - xml = xml.replace(/(^s?\=\s*(.*?$)(\n*(or|not)\=\s*(.*?$))*)+/gm, function(match, p) { - // Line split here, trim off leading xxx= in each function - var answersList = p.split('\n'), - - processNumericalResponse = function (value) { - // Numeric case is just a plain leading = with a single answer - value = value.replace(/^\=\s*/, ''); - var params, answer, string; - - var textHint = extractHint(value); - var hintLine = ''; - if (textHint.hint) { - value = textHint.nothint; - hintLine = ' ' + textHint.hint + '\n' - } - - if (_.contains([ '[', '(' ], value[0]) && _.contains([ ']', ')' ], value[value.length-1]) ) { - // [5, 7) or (5, 7), or (1.2345 * (2+3), 7*4 ] - range tolerance case - // = (5*2)*3 should not be used as range tolerance - string = '\n'; - string += ' \n'; - string += hintLine; - string += '\n\n'; - return string; - } - - if (isNaN(parseFloat(value))) { - return false; - } - - // Tries to extract parameters from string like 'expr +- tolerance' - params = /(.*?)\+\-\s*(.*?$)/.exec(value); - - if(params) { - answer = params[1].replace(/\s+/g, ''); // support inputs like 5*2 +- 10 - string = '\n'; - string += ' \n'; - } else { - answer = value.replace(/\s+/g, ''); // support inputs like 5*2 - string = '\n'; - } - - string += ' \n'; - string += hintLine; - string += '\n\n'; - - return string; - }, - - processStringResponse = function (values) { - // First string case is s?= - var firstAnswer = values.shift(), string; - firstAnswer = firstAnswer.replace(/^s?\=\s*/, ''); - var textHint = extractHint(firstAnswer); - firstAnswer = textHint.nothint; - var typ = ' type="ci"'; - if (firstAnswer[0] == '|') { // this is regexp case - typ = ' type="ci regexp"'; - firstAnswer = firstAnswer.slice(1).trim(); - } - string = '\n'; - if (textHint.hint) { - string += ' ' + textHint.hint + '\n'; - } - - // Subsequent cases are not= or or= - for (i = 0; i < values.length; i += 1) { - var textHint = extractHint(values[i]); - var notMatch = /^not\=\s*(.*)/.exec(textHint.nothint); - if (notMatch) { - string += ' ' + textHint.hint + '\n'; - continue; - } - var orMatch = /^or\=\s*(.*)/.exec(textHint.nothint); - if (orMatch) { - // additional_answer with answer= attribute - string += ' '; - if (textHint.hint) { - string += '' + textHint.hint + ''; - } - string += '\n'; - } - } - - string += ' \n\n\n'; - - return string; - }; - - return processNumericalResponse(answersList[0]) || processStringResponse(answersList); - }); - - - // replace explanations - xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, function(match, p1) { - var selectString = '\n
\n' + gettext('Explanation') + '\n\n' + p1 + '\n
\n
'; - - return selectString; - }); - - // replace code blocks - xml = xml.replace(/\[code\]\n?([^\]]*)\[\/?code\]/gmi, function(match, p1) { - var selectString = '
' + p1 + '
'; - - return selectString; - }); - - // split scripts and preformatted sections, and wrap paragraphs - splits = xml.split(/(\<\/?(?:script|pre|label|description).*?\>)/g); - - // Wrap a string by

tag when line is not already wrapped by another tag - // true when line is not already wrapped by another tag false otherwise - makeParagraph = true; - - for (i = 0; i < splits.length; i += 1) { - if (/\<(script|pre|label|description)/.test(splits[i])) { - makeParagraph = false; - } - - if (makeParagraph) { - splits[i] = splits[i].replace(/(^(?!\s*\<|$).*$)/gm, '

$1

'); - } - - if (/\<\/(script|pre|label|description)/.test(splits[i])) { - makeParagraph = true; - } - } - - xml = splits.join(''); - - // rid white space - xml = xml.replace(/\n\n\n/g, '\n'); - - // if we've come across demand hints, wrap in at the end - if (demandhints) { - demandHintTags.push(demandhints); - } - - // make selector to search responsetypes in xml - var responseTypesSelector = responseTypes.join(', '); - - // make temporary xml - // safe-lint: disable=javascript-concat-html - var $xml = $($.parseXML('' + xml + '')); - responseType = $xml.find(responseTypesSelector); - - // convert if there is only one responsetype - if (responseType.length === 1) { - var inputtype = responseType[0].firstElementChild - // used to decide whether an element should be placed before or after an inputtype - var beforeInputtype = true; - - _.each($xml.find('prob').children(), function(child, index){ - // we don't want to add the responsetype again into new xml - if (responseType[0].nodeName === child.nodeName) { - beforeInputtype = false; - return; - } - - if (beforeInputtype) { - // safe-lint: disable=javascript-jquery-insert-into-target - responseType[0].insertBefore(child, inputtype); - } else { - responseType[0].appendChild(child); - } - }) - var serializer = new XMLSerializer(); - - xml = serializer.serializeToString(responseType[0]); - - // remove xmlns attribute added by the serializer - xml = xml.replace(/\sxmlns=['"].*?['"]/gi, ''); - - // XMLSerializer messes the indentation of XML so add newline - // at the end of each ending tag to make the xml looks better - xml = xml.replace(/(\<\/.*?\>)(\<.*?\>)/gi, '$1\n$2'); - } - - // remove class attribute added on

tag for question title - xml = xml.replace(/\sclass=\'qtitle\'/gi, ''); - return xml; - }` - - responseTypesXML = [] - responseTypesMarkdown = markdown.split(/\n\s*---\s*\n/g) - _.each responseTypesMarkdown, (responseTypeMarkdown, index) -> - if responseTypeMarkdown.trim().length > 0 - responseTypesXML.push toXml(responseTypeMarkdown) - - # combine demandhints - demandHints = '' - if demandHintTags.length - ## safe-lint: disable=javascript-concat-html - demandHints = '\n\n' + demandHintTags.join('') + '' - - # make all responsetypes descendants of a single problem element - ## safe-lint: disable=javascript-concat-html - # format and return xml - finalXml = '' + responseTypesXML.join('\n\n') + demandHints + '' - return PrettyPrint.xml(finalXml); diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.js b/common/lib/xmodule/xmodule/js/src/problem/edit.js new file mode 100644 index 0000000000..9c4c9cf082 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.js @@ -0,0 +1,770 @@ +/* global CodeMirror, _, XModule, PrettyPrint */ +// no-useless-escape disabled because of warnings in regexp expressions within the +// "toXML" code. When the "useless escapes" were removed, some of the unit tests +// failed, but only in Jenkins, indicating browser-specific behavior. +/* eslint no-useless-escape: 0 */ + +(function() { + 'use strict'; + var hasPropsHelper = {}.hasOwnProperty, + extendsHelper = function(child, parent) { + // This helper method was generated by CoffeeScript. Suppressing eslint warnings. + var key; + for (key in parent) { // eslint-disable-line no-restricted-syntax + if (hasPropsHelper.call(parent, key)) { + child[key] = parent[key]; // eslint-disable-line no-param-reassign + } + } + function ctor() { + this.constructor = child; + } + + ctor.prototype = parent.prototype; + child.prototype = new ctor(); // eslint-disable-line no-param-reassign + child.__super__ = parent.prototype; // eslint-disable-line no-param-reassign, no-underscore-dangle + return child; + }; + + this.MarkdownEditingDescriptor = (function(_super) { + // The style of these declarations come from CoffeeScript. Rather than rewriting them, + // the eslint warnings are being suppressed. + extendsHelper(MarkdownEditingDescriptor, _super); // eslint-disable-line no-use-before-define + + MarkdownEditingDescriptor.multipleChoiceTemplate = '( ) ' + // eslint-disable-line no-use-before-define + (gettext('incorrect')) + '\n( ) ' + (gettext('incorrect')) + '\n(x) ' + (gettext('correct')) + '\n'; + + MarkdownEditingDescriptor.checkboxChoiceTemplate = '[x] ' + // eslint-disable-line no-use-before-define + (gettext('correct')) + '\n[ ] incorrect\n[x] correct\n'; + + MarkdownEditingDescriptor.stringInputTemplate = '= ' + // eslint-disable-line no-use-before-define + (gettext('answer')) + '\n'; + + MarkdownEditingDescriptor.numberInputTemplate = '= ' + // eslint-disable-line no-use-before-define + (gettext('answer')) + ' +- 0.001%\n'; + + MarkdownEditingDescriptor.selectTemplate = '[[' + // eslint-disable-line no-use-before-define + (gettext('incorrect')) + ', (' + (gettext('correct')) + '), ' + (gettext('incorrect')) + ']]\n'; + + MarkdownEditingDescriptor.headerTemplate = '' + // eslint-disable-line no-use-before-define + (gettext('Header')) + '\n=====\n'; + + MarkdownEditingDescriptor.explanationTemplate = '[explanation]\n' + // eslint-disable-line no-use-before-define + (gettext('Short explanation')) + '\n[explanation]\n'; + + function MarkdownEditingDescriptor(element) { + var that = this; + this.toggleCheatsheetVisibility = function() { + return MarkdownEditingDescriptor.prototype.toggleCheatsheetVisibility.apply(that, arguments); + }; + this.toggleCheatsheet = function() { + return MarkdownEditingDescriptor.prototype.toggleCheatsheet.apply(that, arguments); + }; + this.onToolbarButton = function() { + return MarkdownEditingDescriptor.prototype.onToolbarButton.apply(that, arguments); + }; + this.onShowXMLButton = function() { + return MarkdownEditingDescriptor.prototype.onShowXMLButton.apply(that, arguments); + }; + this.element = element; + if ($('.markdown-box', this.element).length !== 0) { + this.markdown_editor = CodeMirror.fromTextArea($('.markdown-box', element)[0], { + lineWrapping: true, + mode: null + }); + this.setCurrentEditor(this.markdown_editor); + // Add listeners for toolbar buttons (only present for markdown editor) + this.element.on('click', '.xml-tab', this.onShowXMLButton); + this.element.on('click', '.format-buttons button', this.onToolbarButton); + this.element.on('click', '.cheatsheet-toggle', this.toggleCheatsheet); + // Hide the XML text area + $(this.element.find('.xml-box')).hide(); + } else { + this.createXMLEditor(); + } + } + + /* + Creates the XML Editor and sets it as the current editor. If text is passed in, + it will replace the text present in the HTML template. + + text: optional argument to override the text passed in via the HTML template + */ + MarkdownEditingDescriptor.prototype.createXMLEditor = function(text) { + this.xml_editor = CodeMirror.fromTextArea($('.xml-box', this.element)[0], { + mode: 'xml', + lineNumbers: true, + lineWrapping: true + }); + if (text) { + this.xml_editor.setValue(text); + } + this.setCurrentEditor(this.xml_editor); + $(this.xml_editor.getWrapperElement()).toggleClass('CodeMirror-advanced'); + // Need to refresh to get line numbers to display properly. + this.xml_editor.refresh(); + }; + + /* + User has clicked to show the XML editor. Before XML editor is swapped in, + the user will need to confirm the one-way conversion. + */ + MarkdownEditingDescriptor.prototype.onShowXMLButton = function(e) { + e.preventDefault(); + if (this.cheatsheet && this.cheatsheet.hasClass('shown')) { + this.cheatsheet.toggleClass('shown'); + this.toggleCheatsheetVisibility(); + } + if (this.confirmConversionToXml()) { + this.createXMLEditor(MarkdownEditingDescriptor.markdownToXml(this.markdown_editor.getValue())); + this.xml_editor.setCursor(0); + // Hide markdown-specific toolbar buttons + $(this.element.find('.editor-bar')).hide(); + } + }; + + /* + Have the user confirm the one-way conversion to XML. + Returns true if the user clicked OK, else false. + */ + MarkdownEditingDescriptor.prototype.confirmConversionToXml = function() { + return confirm(gettext('If you use the Advanced Editor, this problem will be converted to XML and you will not be able to return to the Simple Editor Interface.\n\nProceed to the Advanced Editor and convert this problem to XML?')); // eslint-disable-line max-len, no-alert + }; + + /* + Event listener for toolbar buttons (only possible when markdown editor is visible). + */ + MarkdownEditingDescriptor.prototype.onToolbarButton = function(e) { + var revisedSelection, selection; + e.preventDefault(); + selection = this.markdown_editor.getSelection(); + revisedSelection = null; + switch ($(e.currentTarget).attr('class')) { + case 'multiple-choice-button': + revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice(selection); + break; + case 'string-button': + revisedSelection = MarkdownEditingDescriptor.insertStringInput(selection); + break; + case 'number-button': + revisedSelection = MarkdownEditingDescriptor.insertNumberInput(selection); + break; + case 'checks-button': + revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice(selection); + break; + case 'dropdown-button': + revisedSelection = MarkdownEditingDescriptor.insertSelect(selection); + break; + case 'header-button': + revisedSelection = MarkdownEditingDescriptor.insertHeader(selection); + break; + case 'explanation-button': + revisedSelection = MarkdownEditingDescriptor.insertExplanation(selection); + break; + default: + break; + } + if (revisedSelection !== null) { + this.markdown_editor.replaceSelection(revisedSelection); + this.markdown_editor.focus(); + } + }; + + /* + Event listener for toggling cheatsheet (only possible when markdown editor is visible). + */ + MarkdownEditingDescriptor.prototype.toggleCheatsheet = function(e) { + var that = this; + e.preventDefault(); + if (!$(this.markdown_editor.getWrapperElement()).find('.simple-editor-cheatsheet')[0]) { + this.cheatsheet = $($('#simple-editor-cheatsheet').html()); + $(this.markdown_editor.getWrapperElement()).append(this.cheatsheet); + } + this.toggleCheatsheetVisibility(); + return setTimeout((function() { + return that.cheatsheet.toggleClass('shown'); + }), 10); + }; + + /* + Function to toggle cheatsheet visibility. + */ + MarkdownEditingDescriptor.prototype.toggleCheatsheetVisibility = function() { + return $('.modal-content').toggleClass('cheatsheet-is-shown'); + }; + + /* + Stores the current editor and hides the one that is not displayed. + */ + MarkdownEditingDescriptor.prototype.setCurrentEditor = function(editor) { + if (this.current_editor) { + $(this.current_editor.getWrapperElement()).hide(); + } + this.current_editor = editor; + $(this.current_editor.getWrapperElement()).show(); + return $(this.current_editor).focus(); + }; + + /* + Called when save is called. Listeners are unregistered because editing the block again will + result in a new instance of the descriptor. Note that this is NOT the case for cancel-- + when cancel is called the instance of the descriptor is reused if edit is selected again. + */ + MarkdownEditingDescriptor.prototype.save = function() { + this.element.off('click', '.xml-tab', this.changeEditor); + this.element.off('click', '.format-buttons button', this.onToolbarButton); + this.element.off('click', '.cheatsheet-toggle', this.toggleCheatsheet); + if (this.current_editor === this.markdown_editor) { + return { + data: MarkdownEditingDescriptor.markdownToXml(this.markdown_editor.getValue()), + metadata: { + markdown: this.markdown_editor.getValue() + } + }; + } else { + return { + data: this.xml_editor.getValue(), + nullout: ['markdown'] + }; + } + }; + + MarkdownEditingDescriptor.insertMultipleChoice = function(selectedText) { + return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '(', ')', + MarkdownEditingDescriptor.multipleChoiceTemplate + ); + }; + + MarkdownEditingDescriptor.insertCheckboxChoice = function(selectedText) { + return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '[', ']', + MarkdownEditingDescriptor.checkboxChoiceTemplate + ); + }; + + MarkdownEditingDescriptor.insertGenericChoice = function(selectedText, choiceStart, choiceEnd, template) { + var cleanSelectedText, line, lines, revisedLines, i, len; + if (selectedText.length > 0) { + // Replace adjacent newlines with a single newline, strip any trailing newline + cleanSelectedText = selectedText.replace(/\n+/g, '\n').replace(/\n$/, ''); + lines = cleanSelectedText.split('\n'); + revisedLines = ''; + for (i = 0, len = lines.length; i < len; i++) { + line = lines[i]; + revisedLines += choiceStart; + // a stand alone x before other text implies that this option is "correct" + if (/^\s*x\s+(\S)/i.test(line)) { + // Remove the x and any initial whitespace as long as there's more text on the line + line = line.replace(/^\s*x\s+(\S)/i, '$1'); + revisedLines += 'x'; + } else { + revisedLines += ' '; + } + revisedLines += choiceEnd + ' ' + line + '\n'; + } + return revisedLines; + } else { + return template; + } + }; + + MarkdownEditingDescriptor.insertStringInput = function(selectedText) { + return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', + MarkdownEditingDescriptor.stringInputTemplate + ); + }; + + MarkdownEditingDescriptor.insertNumberInput = function(selectedText) { + return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', + MarkdownEditingDescriptor.numberInputTemplate + ); + }; + + MarkdownEditingDescriptor.insertSelect = function(selectedText) { + return MarkdownEditingDescriptor.insertGenericInput(selectedText, '[[', ']]', + MarkdownEditingDescriptor.selectTemplate + ); + }; + + MarkdownEditingDescriptor.insertHeader = function(selectedText) { + return MarkdownEditingDescriptor.insertGenericInput(selectedText, '', '\n====\n', + MarkdownEditingDescriptor.headerTemplate + ); + }; + + MarkdownEditingDescriptor.insertExplanation = function(selectedText) { + return MarkdownEditingDescriptor.insertGenericInput(selectedText, '[explanation]\n', '\n[explanation]', + MarkdownEditingDescriptor.explanationTemplate + ); + }; + + MarkdownEditingDescriptor.insertGenericInput = function(selectedText, lineStart, lineEnd, template) { + if (selectedText.length > 0) { + return lineStart + selectedText + lineEnd; + } else { + return template; + } + }; + + MarkdownEditingDescriptor.markdownToXml = function(markdown) { + var demandHintTags = [], + finalDemandHints, finalXml, responseTypesMarkdown, responseTypesXML, toXml; + toXml = function(partialMarkdown) { + var xml = partialMarkdown, + i, splits, makeParagraph, serializer, responseType, $xml, responseTypesSelector, + inputtype, beforeInputtype, extractHint, demandhints; + var responseTypes = [ + 'optionresponse', 'multiplechoiceresponse', 'stringresponse', 'numericalresponse', 'choiceresponse' + ]; + + // fix DOS \r\n line endings to look like \n + xml = xml.replace(/\r\n/g, '\n'); + + // replace headers + xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '

$1

'); + xml = xml.replace(/\n^\=\=+$/gm, ''); + + // extract question and description(optional) + // >>question||description<< converts to + // description + xml = xml.replace(/>>([^]+?)<\n'; + + // don't add empty tag + if (result.length === 1 || !result[1]) { + return label; + } + return label + '' + result[1] + '\n'; + }); + + // Pull out demand hints, || a hint || + demandhints = ''; + xml = xml.replace(/(^\s*\|\|.*?\|\|\s*$\n?)+/gm, function(match) { // $\n + var inner, + options = match.split('\n'); + for (i = 0; i < options.length; i += 1) { + inner = /\s*\|\|(.*?)\|\|/.exec(options[i]); + if (inner) { + // safe-lint: disable=javascript-concat-html + demandhints += ' ' + inner[1].trim() + '\n'; + } + } + return ''; + }); + + // replace \n+whitespace within extended hint {{ .. }}, by a space, so the whole + // hint sits on one line. + // This is the one instance of {{ ... }} matching that permits \n + xml = xml.replace(/{{(.|\n)*?}}/gm, function(match) { + return match.replace(/\r?\n( |\t)*/g, ' '); + }); + + // Function used in many places to extract {{ label:: a hint }}. + // Returns a little hash with various parts of the hint: + // hint: the hint or empty, nothint: the rest + // labelassign: javascript assignment of label attribute, or empty + extractHint = function(inputText, detectParens) { + var text = inputText, + curly = /\s*{{(.*?)}}/.exec(text), + hint = '', + label = '', + parens = false, + labelassign = '', + labelmatch; + if (curly) { + text = text.replace(curly[0], ''); + hint = curly[1].trim(); + labelmatch = /^(.*?)::/.exec(hint); + if (labelmatch) { + hint = hint.replace(labelmatch[0], '').trim(); + label = labelmatch[1].trim(); + labelassign = ' label="' + label + '"'; + } + } + if (detectParens) { + if (text.length >= 2 && text[0] === '(' && text[text.length - 1] === ')') { + text = text.substring(1, text.length - 1); + parens = true; + } + } + return { + nothint: text, + hint: hint, + label: label, + parens: parens, + labelassign: labelassign + }; + }; + + + // replace selects + // [[ a, b, (c) ]] + // [[ + // a + // b + // (c) + // ]] + // + // + // \n\n'; + } + + // new style [[ many-lines ]] + lines = group1.split('\n'); + optionlines = ''; + for (i = 0; i < lines.length; i++) { + line = lines[i].trim(); + if (line.length > 0) { + textHint = extractHint(line, true); + correctstr = ' correct="' + (textHint.parens ? 'True' : 'False') + '"'; + hintstr = ''; + if (textHint.hint) { + label = textHint.label; + if (label) { + label = ' label="' + label + '"'; + } + hintstr = ' ' + textHint.hint + ''; + } + optionlines += ' ' + textHint.nothint + hintstr + + '\n'; + } + } + return '\n\n \n' + optionlines + + ' \n\n\n'; + }); + + // multiple choice questions + // + xml = xml.replace(/(^\s*\(.{0,3}\).*?$\n*)+/gm, function(match) { + var choices = '', + shuffle = false, + options = match.split('\n'), + value, inparens, correct, + fixed, hint, result; + for (i = 0; i < options.length; i++) { + options[i] = options[i].trim(); // trim off leading/trailing whitespace + if (options[i].length > 0) { + value = options[i].split(/^\s*\(.{0,3}\)\s*/)[1]; + inparens = /^\s*\((.{0,3})\)\s*/.exec(options[i])[1]; + correct = /x/i.test(inparens); + fixed = ''; + if (/@/.test(inparens)) { + fixed = ' fixed="true"'; + } + if (/!/.test(inparens)) { + shuffle = true; + } + + hint = extractHint(value); + if (hint.hint) { + value = hint.nothint; + value = value + ' ' + hint.hint + ''; + } + choices += ' ' + value + '\n'; + } + } + result = '\n'; + if (shuffle) { + result += ' \n'; + } else { + result += ' \n'; + } + result += choices; + result += ' \n'; + result += '\n\n'; + return result; + }); + + // group check answers + // [.] with {{...}} lines mixed in + xml = xml.replace(/(^\s*((\[.?\])|({{.*?}})).*?$\n*)+/gm, function(match) { + var groupString = '\n', + options = match.split('\n'), + value, correct, abhint, endHints, hintbody, + hint, inner, select, hints; + + groupString += ' \n'; + endHints = ''; // save these up to emit at the end + + for (i = 0; i < options.length; i += 1) { + if (options[i].trim().length > 0) { + // detect the {{ ((A*B)) ...}} case first + // emits: AB hint + + abhint = /^\s*{{\s*\(\((.*?)\)\)(.*?)}}/.exec(options[i]); + if (abhint) { + // lone case of hint text processing outside of extractHint, since syntax here is unique + hintbody = abhint[2]; + hintbody = hintbody.replace('&lf;', '\n').trim(); + endHints += ' ' + hintbody + + '\n'; + continue; // bail + } + + value = options[i].split(/^\s*\[.?\]\s*/)[1]; + correct = /^\s*\[x\]/i.test(options[i]); + hints = ''; + // {{ selected: You’re right that apple is a fruit. }, + // {unselected: Remember that apple is also a fruit.}} + hint = extractHint(value); + if (hint.hint) { + inner = '{' + hint.hint + '}'; // parsing is easier if we put outer { } back + + // include \n since we are downstream of extractHint() + select = /{\s*(s|selected):((.|\n)*?)}/i.exec(inner); + // checkbox choicehints get their own line, since there can be two of them + // You’re right that apple is a fruit. + if (select) { + hints += '\n ' + select[2].trim() + + ''; + } + select = /{\s*(u|unselected):((.|\n)*?)}/i.exec(inner); + if (select) { + hints += '\n ' + select[2].trim() + + ''; + } + + // Blank out the original text only if the specific "selected" syntax is found + // That way, if the user types it wrong, at least they can see it's not processed. + if (hints) { + value = hint.nothint; + } + } + groupString += ' ' + value + hints + '\n'; + } + } + + groupString += endHints; + groupString += ' \n'; + groupString += '\n\n'; + + return groupString; + }); + + + // replace string and numerical, numericalresponse, stringresponse + // A fine example of the function-composition programming style. + xml = xml.replace(/(^s?\=\s*(.*?$)(\n*(or|not)\=\s*(.*?$))*)+/gm, function(match, p) { + // Line split here, trim off leading xxx= in each function + var answersList = p.split('\n'), + + processNumericalResponse = function(val) { + var params, answer, string, textHint, hintLine, value; + // Numeric case is just a plain leading = with a single answer + value = val.replace(/^\=\s*/, ''); + + textHint = extractHint(value); + hintLine = ''; + if (textHint.hint) { + value = textHint.nothint; + hintLine = ' ' + textHint.hint + + '\n'; + } + + if (_.contains(['[', '('], value[0]) && _.contains([']', ')'], value[value.length - 1])) { + // [5, 7) or (5, 7), or (1.2345 * (2+3), 7*4 ] - range tolerance case + // = (5*2)*3 should not be used as range tolerance + string = '\n'; + string += ' \n'; + string += hintLine; + string += '\n\n'; + return string; + } + + if (isNaN(parseFloat(value))) { + return false; + } + + // Tries to extract parameters from string like 'expr +- tolerance' + params = /(.*?)\+\-\s*(.*?$)/.exec(value); + + if (params) { + answer = params[1].replace(/\s+/g, ''); // support inputs like 5*2 +- 10 + string = '\n'; + string += ' \n'; + } else { + answer = value.replace(/\s+/g, ''); // support inputs like 5*2 + string = '\n'; + } + + string += ' \n'; + string += hintLine; + string += '\n\n'; + + return string; + }, + + processStringResponse = function(values) { + var firstAnswer, textHint, typ, string, orMatch, notMatch; + // First string case is s?= + firstAnswer = values.shift(); + firstAnswer = firstAnswer.replace(/^s?\=\s*/, ''); + textHint = extractHint(firstAnswer); + firstAnswer = textHint.nothint; + typ = ' type="ci"'; + if (firstAnswer[0] === '|') { // this is regexp case + typ = ' type="ci regexp"'; + firstAnswer = firstAnswer.slice(1).trim(); + } + string = '\n'; + if (textHint.hint) { + string += ' ' + + textHint.hint + '\n'; + } + + // Subsequent cases are not= or or= + for (i = 0; i < values.length; i += 1) { + textHint = extractHint(values[i]); + notMatch = /^not\=\s*(.*)/.exec(textHint.nothint); + if (notMatch) { + string += ' ' + textHint.hint + '\n'; + continue; + } + orMatch = /^or\=\s*(.*)/.exec(textHint.nothint); + if (orMatch) { + // additional_answer with answer= attribute + string += ' '; + if (textHint.hint) { + string += '' + + textHint.hint + ''; + } + string += '\n'; + } + } + + string += ' \n\n\n'; + + return string; + }; + + return processNumericalResponse(answersList[0]) || processStringResponse(answersList); + }); + + + // replace explanations + xml = xml.replace(/\[explanation\]\n?([^\]]*)\[\/?explanation\]/gmi, function(match, p1) { + return '\n
\n' + + gettext('Explanation') + '\n\n' + p1 + '\n
\n
'; + }); + + // replace code blocks + xml = xml.replace(/\[code\]\n?([^\]]*)\[\/?code\]/gmi, function(match, p1) { + return '
' + p1 + '
'; + }); + + // split scripts and preformatted sections, and wrap paragraphs + splits = xml.split(/(\<\/?(?:script|pre|label|description).*?\>)/g); + + // Wrap a string by

tag when line is not already wrapped by another tag + // true when line is not already wrapped by another tag false otherwise + makeParagraph = true; + + for (i = 0; i < splits.length; i += 1) { + if (/\<(script|pre|label|description)/.test(splits[i])) { + makeParagraph = false; + } + + if (makeParagraph) { + splits[i] = splits[i].replace(/(^(?!\s*\<|$).*$)/gm, '

$1

'); + } + + if (/\<\/(script|pre|label|description)/.test(splits[i])) { + makeParagraph = true; + } + } + + xml = splits.join(''); + + // rid white space + xml = xml.replace(/\n\n\n/g, '\n'); + + // if we've come across demand hints, wrap in at the end + if (demandhints) { + demandHintTags.push(demandhints); + } + + // make selector to search responsetypes in xml + responseTypesSelector = responseTypes.join(', '); + + // make temporary xml + // safe-lint: disable=javascript-concat-html + $xml = $($.parseXML('' + xml + '')); + responseType = $xml.find(responseTypesSelector); + + // convert if there is only one responsetype + if (responseType.length === 1) { + inputtype = responseType[0].firstElementChild; + // used to decide whether an element should be placed before or after an inputtype + beforeInputtype = true; + + _.each($xml.find('prob').children(), function(child) { + // we don't want to add the responsetype again into new xml + if (responseType[0].nodeName === child.nodeName) { + beforeInputtype = false; + return; + } + + if (beforeInputtype) { + // safe-lint: disable=javascript-jquery-insert-into-target + responseType[0].insertBefore(child, inputtype); + } else { + responseType[0].appendChild(child); + } + }); + serializer = new XMLSerializer(); + + xml = serializer.serializeToString(responseType[0]); + + // remove xmlns attribute added by the serializer + xml = xml.replace(/\sxmlns=['"].*?['"]/gi, ''); + + // XMLSerializer messes the indentation of XML so add newline + // at the end of each ending tag to make the xml looks better + xml = xml.replace(/(\<\/.*?\>)(\<.*?\>)/gi, '$1\n$2'); + } + + // remove class attribute added on

tag for question title + xml = xml.replace(/\sclass=\'qtitle\'/gi, ''); + return xml; + }; + responseTypesXML = []; + responseTypesMarkdown = markdown.split(/\n\s*---\s*\n/g); + _.each(responseTypesMarkdown, function(responseTypeMarkdown) { + if (responseTypeMarkdown.trim().length > 0) { + responseTypesXML.push(toXml(responseTypeMarkdown)); + } + }); + finalDemandHints = ''; + if (demandHintTags.length) { + // safe-lint: disable=javascript-concat-html + finalDemandHints = '\n\n' + demandHintTags.join('') + ''; + } + // make all responsetypes descendants of a single problem element + // safe-lint: disable=javascript-concat-html + finalXml = '' + responseTypesXML.join('\n\n') + finalDemandHints + ''; + return PrettyPrint.xml(finalXml); + }; + + return MarkdownEditingDescriptor; + }(XModule.Descriptor)); +}).call(this);