Add matrix support \matrix \pmatrix \bmatrix \Bmatrix \vmatrix \Vmatrix
diff --git a/src/commands/math/LatexCommandInput.js b/src/commands/math/LatexCommandInput.js index fb4e18e..4382041 100644 --- a/src/commands/math/LatexCommandInput.js +++ b/src/commands/math/LatexCommandInput.js
@@ -74,7 +74,7 @@ var latex = this.ends[L].latex(); if (!latex) latex = ' '; - var cmd = LatexCmds[latex]; + var cmd = LatexCmds[latex] || Environments[latex]; if (cmd) { cmd = cmd(latex); if (this._replacedFragment) cmd.replaces(this._replacedFragment);
diff --git a/src/commands/math/commands.js b/src/commands/math/commands.js index 53fb861..3b347de 100644 --- a/src/commands/math/commands.js +++ b/src/commands/math/commands.js
@@ -851,3 +851,450 @@ ; }; }); + +// LaTeX environments +// Environments are delimited by an opening \begin{} and a closing +// \end{}. Everything inside those tags will be formatted in a +// special manner depending on the environment type. +var Environments = {}; + +LatexCmds.begin = P(MathCommand, function(_, super_) { + _.parser = function() { + var string = Parser.string; + var regex = Parser.regex; + return string('{') + .then(regex(/^[a-z]+/i)) + .skip(string('}')) + .then(function (env) { + return (Environments[env] ? + Environments[env]().parser() : + Parser.fail('unknown environment type: '+env) + ).skip(string('\\end{'+env+'}')); + }) + ; + }; +}); + +var Environment = P(MathCommand, function(_, super_) { + _.template = [['\\begin{', '}'], ['\\end{', '}']]; + _.wrappers = function () { + return [ + _.template[0].join(this.environment), + _.template[1].join(this.environment) + ]; + }; +}); + +var Matrix = +Environments.matrix = P(Environment, function(_, super_) { + + var delimiters = { + column: '&', + row: '\\\\' + }; + _.parentheses = { + left: null, + right: null + }; + _.environment = 'matrix'; + + _.reflow = function() { + var blockjQ = this.jQ.children('table'); + + var height = blockjQ.outerHeight()/+blockjQ.css('fontSize').slice(0,-2); + + var parens = this.jQ.children('.mq-paren'); + if (parens.length) { + scale(parens, min(1 + .2*(height - 1), 1.2), 1.05*height); + } + }; + _.latex = function() { + var latex = ''; + var row; + + this.eachChild(function (cell) { + if (typeof row !== 'undefined') { + latex += (row !== cell.row) ? + delimiters.row : + delimiters.column; + } + row = cell.row; + latex += cell.latex(); + }); + + return this.wrappers().join(latex); + }; + _.html = function() { + var cells = [], trs = '', i=0, row; + + function parenHtml(paren) { + return (paren) ? + '<span class="mq-scaled mq-paren">' + + paren + + '</span>' : ''; + } + + // Build <tr><td>.. structure from cells + this.eachChild(function (cell) { + if (row !== cell.row) { + row = cell.row; + trs += '<tr>$tds</tr>'; + cells[row] = []; + } + cells[row].push('<td>&'+(i++)+'</td>'); + }); + + this.htmlTemplate = + '<span class="mq-matrix mq-non-leaf">' + + parenHtml(this.parentheses.left) + + '<table class="mq-non-leaf">' + + trs.replace(/\$tds/g, function () { + return cells.shift().join(''); + }) + + '</table>' + + parenHtml(this.parentheses.right) + + '</span>' + ; + + return super_.html.call(this); + }; + // Create default 4-cell matrix + _.createBlocks = function() { + this.blocks = [ + MatrixCell(0, this), + MatrixCell(0, this), + MatrixCell(1, this), + MatrixCell(1, this) + ]; + }; + _.parser = function() { + var self = this; + var optWhitespace = Parser.optWhitespace; + var string = Parser.string; + + return optWhitespace + .then(string(delimiters.column) + .or(string(delimiters.row)) + .or(latexMathParser.block)) + .many() + .skip(optWhitespace) + .then(function(items) { + var blocks = []; + var row = 0; + self.blocks = []; + + function addCell() { + self.blocks.push(MatrixCell(row, self, blocks)); + blocks = []; + } + + for (var i=0; i<items.length; i+=1) { + if (items[i] instanceof MathBlock) { + blocks.push(items[i]); + } else { + addCell(); + if (items[i] === delimiters.row) row+=1; + } + } + addCell(); + self.autocorrect(); + return Parser.succeed(self); + }); + }; + // Relink all the cells after parsing + _.finalizeTree = function() { + var table = this.jQ.find('table'); + table.toggleClass('mq-rows-1', table.find('tr').length === 1); + this.relink(); + }; + // Set up directional pointers between cells + _.relink = function() { + var blocks = this.blocks; + var rows = []; + var row, column, cell; + + // Use a for loop rather than eachChild + // as we're still making sure children() + // is set up properly + for (var i=0; i<blocks.length; i+=1) { + cell = blocks[i]; + if (row !== cell.row) { + row = cell.row; + rows[row] = []; + column = 0; + } + rows[row][column] = cell; + + // Set up horizontal linkage + cell[R] = blocks[i+1]; + cell[L] = blocks[i-1]; + + // Set up vertical linkage + if (rows[row-1] && rows[row-1][column]) { + cell.upOutOf = rows[row-1][column]; + rows[row-1][column].downOutOf = cell; + } + + column+=1; + } + + // set start and end blocks of matrix + this.ends[L] = blocks[0]; + this.ends[R] = blocks[blocks.length-1]; + }; + // Ensure consistent row lengths + _.autocorrect = function(rows) { + var lengths = [], rows = []; + var blocks = this.blocks; + var maxLength, shortfall, position, row, i; + + for (i=0; i<blocks.length; i+=1) { + row = blocks[i].row; + rows[row] = rows[row] || []; + rows[row].push(blocks[i]); + lengths[row] = rows[row].length; + } + + maxLength = Math.max.apply(null, lengths); + if (maxLength !== Math.min.apply(null, lengths)) { + // Pad shorter rows to correct length + for (i=0; i<rows.length; i+=1) { + shortfall = maxLength - rows[i].length; + while (shortfall) { + position = maxLength*i + rows[i].length; + blocks.splice(position, 0, MatrixCell(i, this)); + shortfall-=1; + } + } + this.relink(); + } + }; + // Deleting a cell will also delete the current row and + // column if they are empty, and relink the matrix. + _.deleteCell = function(currentCell) { + var rows = [], columns = [], myRow = [], myColumn = []; + var blocks = this.blocks, row, column; + + // Create arrays for cells in the current row / column + this.eachChild(function (cell) { + if (row !== cell.row) { + row = cell.row; + rows[row] = []; + column = 0; + } + columns[column] = columns[column] || []; + columns[column].push(cell); + rows[row].push(cell); + + if (cell === currentCell) { + myRow = rows[row]; + myColumn = columns[column]; + } + + column+=1; + }); + + function isEmpty(cells) { + var empties = []; + for (var i=0; i<cells.length; i+=1) { + if (cells[i].isEmpty()) empties.push(cells[i]); + } + return empties.length === cells.length; + } + + function remove(cells) { + for (var i=0; i<cells.length; i+=1) { + if (blocks.indexOf(cells[i]) > -1) { + cells[i].remove(); + blocks.splice(blocks.indexOf(cells[i]), 1); + } + } + } + + if (isEmpty(myRow) && myColumn.length > 1) { + row = rows.indexOf(myRow); + // Decrease all following row numbers + this.eachChild(function (cell) { + if (cell.row > row) cell.row-=1; + }); + // Dispose of cells and remove <tr> + remove(myRow); + this.jQ.find('tr').eq(row).remove(); + } + if (isEmpty(myColumn) && myRow.length > 1) { + remove(myColumn); + } + this.finalizeTree(); + }; + _.addRow = function(afterCell) { + var previous = [], newCells = [], next = []; + var newRow = $('<tr></tr>'), row = afterCell.row; + var columns = 0, block, column; + + this.eachChild(function (cell) { + // Cache previous rows + if (cell.row <= row) { + previous.push(cell); + } + // Work out how many columns + if (cell.row === row) { + if (cell === afterCell) column = columns; + columns+=1; + } + // Cache cells after new row + if (cell.row > row) { + cell.row+=1; + next.push(cell); + } + }); + + // Add new cells, one for each column + for (var i=0; i<columns; i+=1) { + block = MatrixCell(row+1); + block.parent = this; + newCells.push(block); + + // Create cell <td>s and add to new row + block.jQ = $('<td class="mq-empty">') + .attr(mqBlockId, block.id) + .appendTo(newRow); + } + + // Insert the new row + this.jQ.find('tr').eq(row).after(newRow); + this.blocks = previous.concat(newCells, next); + return newCells[column]; + }; + _.addColumn = function(afterCell) { + var rows = [], newCells = []; + var column, block; + + // Build rows array and find new column index + this.eachChild(function (cell) { + rows[cell.row] = rows[cell.row] || []; + rows[cell.row].push(cell); + if (cell === afterCell) column = rows[cell.row].length; + }); + + // Add new cells, one for each row + for (var i=0; i<rows.length; i+=1) { + block = MatrixCell(i); + block.parent = this; + newCells.push(block); + rows[i].splice(column, 0, block); + + block.jQ = $('<td class="mq-empty">') + .attr(mqBlockId, block.id); + } + + // Add cell <td> elements in correct positions + this.jQ.find('tr').each(function (i) { + $(this).find('td').eq(column-1).after(rows[i][column].jQ); + }); + + // Flatten the rows array-of-arrays + this.blocks = [].concat.apply([], rows); + return newCells[afterCell.row]; + }; + _.insert = function(method, afterCell) { + var cellToFocus = this[method](afterCell); + this.cursor = this.cursor || this.parent.cursor; + this.finalizeTree(); + this.bubble('reflow').cursor.insAtRightEnd(cellToFocus); + }; + _.backspace = function(cell, dir, cursor, finalDeleteCallback) { + var dirwards = cell[dir]; + if (cell.isEmpty()) { + this.deleteCell(cell); + while (dirwards && + dirwards[dir] && + this.blocks.indexOf(dirwards) === -1) { + dirwards = dirwards[dir]; + } + if (dirwards) { + cursor.insAtDirEnd(-dir, dirwards); + } + if (this.blocks.length === 1 && this.blocks[0].isEmpty()) { + finalDeleteCallback(); + this.finalizeTree(); + } + this.bubble('edited'); + } + }; +}); + +Environments.pmatrix = P(Matrix, function(_, super_) { + _.environment = 'pmatrix'; + _.parentheses = { + left: '(', + right: ')' + }; +}); + +Environments.bmatrix = P(Matrix, function(_, super_) { + _.environment = 'bmatrix'; + _.parentheses = { + left: '[', + right: ']' + }; +}); + +Environments.Bmatrix = P(Matrix, function(_, super_) { + _.environment = 'Bmatrix'; + _.parentheses = { + left: '{', + right: '}' + }; +}); + +Environments.vmatrix = P(Matrix, function(_, super_) { + _.environment = 'vmatrix'; + _.parentheses = { + left: '|', + right: '|' + }; +}); + +Environments.Vmatrix = P(Matrix, function(_, super_) { + _.environment = 'Vmatrix'; + _.parentheses = { + left: '‖', + right: '‖' + }; +}); + +// Replacement for mathblocks inside matrix cells +// Adds matrix-specific keyboard commands +var MatrixCell = P(MathBlock, function(_, super_) { + _.init = function(row, parent, replaces) { + super_.init.call(this); + this.row = row; + if (parent) { + this.adopt(parent, parent.ends[R], 0); + } + if (replaces) { + for (var i=0; i<replaces.length; i++) { + replaces[i].children().adopt(this, this.ends[R], 0); + } + } + }; + _.keystroke = function(key, e, ctrlr) { + switch (key) { + case 'Shift-Spacebar': + e.preventDefault(); + return this.parent.insert('addColumn', this); + break; + case 'Shift-Enter': + return this.parent.insert('addRow', this); + break; + } + return super_.keystroke.apply(this, arguments); + }; + _.deleteOutOf = function(dir, cursor) { + var self = this, args = arguments; + this.parent.backspace(this, dir, cursor, function () { + // called when last cell gets deleted + return super_.deleteOutOf.apply(self, args); + }); + } +});
diff --git a/src/css/math.less b/src/css/math.less index bff1201..b27a3ab 100644 --- a/src/css/math.less +++ b/src/css/math.less
@@ -373,4 +373,29 @@ -ms-filter: "FlipH"; } } + + .mq-matrix { + vertical-align: middle; + margin-left: 0.1em; + margin-right: 0.1em; + + table { + width: auto; + border-bottom: none; + border-spacing: 3px; + border-collapse: separate; + &.mq-rows-1 { /* better alignment when there's just one row */ + vertical-align: middle; + margin-bottom: 1px; + } + } + + td { + border: none; + width: auto; /* defensive resets */ + padding: 0.1em 0.3em; + vertical-align: baseline; + } + } + }
diff --git a/src/publicapi.js b/src/publicapi.js index 84b13db..d3d1ab3 100644 --- a/src/publicapi.js +++ b/src/publicapi.js
@@ -164,7 +164,7 @@ var ctrlr = this.__controller.notify(), cursor = ctrlr.cursor; if (/^\\[a-z]+$/i.test(cmd)) { cmd = cmd.slice(1); - var klass = LatexCmds[cmd]; + var klass = LatexCmds[cmd] || Environments[cmd]; if (klass) { cmd = klass(cmd); if (cursor.selection) cmd.replaces(cursor.replaceSelection());
diff --git a/test/matrix.html b/test/matrix.html new file mode 100644 index 0000000..fd3175f --- /dev/null +++ b/test/matrix.html
@@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html> +<head> + +<title>MathQuill Matrix Test</title> + +<link rel="stylesheet" type="text/css" href="../build/mathquill.css"> +<script type="text/javascript" src="support/jquery-1.7.2.js"></script> +<script type="text/javascript" src="../build/mathquill.js"></script> +</head> +<body> + +<h3>MathQuill Matrix Test</h3> +<span id="example"> +\begin{Bmatrix}1&2\\x&y\end{Bmatrix} +</span> + +<script> +MathQuill = MathQuill.getInterface(1); +instance = MathQuill.MathField(document.getElementById('example')); +console.log(instance.latex()); +</script> + +</body> +</html>
diff --git a/test/unit/latex.test.js b/test/unit/latex.test.js index e6e4096..afa3a32 100644 --- a/test/unit/latex.test.js +++ b/test/unit/latex.test.js
@@ -137,6 +137,18 @@ assertParsesLatex('\\square '); }); + test('matrices', function() { + assertParsesLatex('\\begin{matrix}x\\end{matrix}'); + assertParsesLatex('\\begin{pmatrix}x\\end{pmatrix}'); + assertParsesLatex('\\begin{Bmatrix}x\\end{Bmatrix}'); + assertParsesLatex('\\begin{vmatrix}x&y\\\\1&2\\end{vmatrix}'); + assertParsesLatex('\\begin{bmatrix}x&y&z&123&x^2\\\\23&s&\\sin \\theta &1&x\\\\e&h&a&1&y\\end{bmatrix}'); + + // Adds missing cells + assertParsesLatex('\\begin{Vmatrix}x&y\\\\1\\end{Vmatrix}', '\\begin{Vmatrix}x&y\\\\1&\\end{Vmatrix}'); + assertParsesLatex('\\begin{Vmatrix}x\\\\x&y\\\\x\\end{Vmatrix}', '\\begin{Vmatrix}x&\\\\x&y\\\\x&\\end{Vmatrix}'); + }); + suite('public API', function() { var mq; setup(function() {
diff --git a/test/unit/typing.test.js b/test/unit/typing.test.js index c2f8462..33d4163 100644 --- a/test/unit/typing.test.js +++ b/test/unit/typing.test.js
@@ -1082,4 +1082,69 @@ assertLatex('\\times'); }); }); + + suite('Matrices', function() { + test('add matrix via mq.write', function() { + mq.write('\\begin{matrix}x&y\\\\1&2\\end{matrix}'); + assertLatex('\\begin{matrix}x&y\\\\1&2\\end{matrix}'); + }); + + test('key bindings add rows and columns to matrix', function() { + mq.write('\\begin{matrix}x\\end{matrix}').keystroke('Left'); + + mq.keystroke('Shift-Spacebar'); + assertLatex('\\begin{matrix}x&\\end{matrix}'); + + mq.keystroke('Shift-Enter'); + assertLatex('\\begin{matrix}x&\\\\&\\end{matrix}'); + }); + + test('key sequence populates matrix', function() { + mq.write('\\begin{matrix}x&\\\\&\\end{matrix}') + .keystroke('Left Left').typedText('a') + .keystroke('Right').typedText('b') + .keystroke('Up').typedText('y'); + + assertLatex('\\begin{matrix}x&y\\\\a&b\\end{matrix}'); + }); + + test('cursor keys navigate around matrix', function() { + mq.write('\\begin{matrix}&&\\\\&&\\\\&&\\end{matrix}'); + + mq.keystroke('Left Left Left').typedText('a') + .keystroke('Up').typedText('b') + .keystroke('Right').typedText('c') + .keystroke('Down').typedText('d'); + + assertLatex('\\begin{matrix}&&\\\\b&c&\\\\a&d&\\end{matrix}'); + }); + + test('delete key removes empty matrix row/column', function() { + mq.write('\\begin{matrix}a&&b\\\\&c&d\\\\&e&f\\end{matrix}'); + + // Row is not yet deleted as there was content + mq.keystroke('Left Backspace Left'); + assertLatex('\\begin{matrix}a&&b\\\\&c&d\\\\&e&\\end{matrix}'); + + // Row is now deleted (delete e, then row) + mq.keystroke('Backspace Backspace'); + assertLatex('\\begin{matrix}a&&b\\\\&c&d\\end{matrix}'); + + // Column is now deleted (delete c, then column) + mq.keystroke('Left Left Backspace Backspace'); + assertLatex('\\begin{matrix}a&b\\\\&d\\end{matrix}'); + }); + + test('brackets are scaled immediately', function() { + mq.write('\\begin{bmatrix}x\\end{bmatrix}'); + function bracketHeight() { + return $(mq.el()).find('.mq-matrix .mq-paren.mq-scaled')[0].getBoundingClientRect().height; + } + var height = bracketHeight(); + mq.keystroke('Left Shift-Enter'); + + assert.ok(bracketHeight() > height, + 'matrix bracket height should be increased when new row is added'); + }); + }); });
diff --git a/test/visual.html b/test/visual.html index 1f77a3c..42a9684 100644 --- a/test/visual.html +++ b/test/visual.html
@@ -178,9 +178,9 @@ <table id="dynamic-reflow"> <tr><th colspan=3><code>MQ(...).reflow()</code> <tr> - <td><span>\sqrt{ \left ( \frac{x^2 + y^2}{2} \right ) } + \binom{n}{k}</span> - <td><span>\sqrt{ \left ( \frac{x^2 + y^2}{2} \right ) } + \binom{n}{k}</span> - <td><span>\sqrt{ \left ( \frac{x^2 + y^2}{2} \right ) } + \binom{n}{k}</span> + <td><span>\sqrt{ \left ( \frac{x^2 + y^2}{2} \right ) } + \binom{n}{k} + \begin{bmatrix}x&y\\1&2\end{bmatrix}</span> + <td><span>\sqrt{ \left ( \frac{x^2 + y^2}{2} \right ) } + \binom{n}{k} + \begin{bmatrix}x&y\\1&2\end{bmatrix}</span> + <td><span>\sqrt{ \left ( \frac{x^2 + y^2}{2} \right ) } + \binom{n}{k} + \begin{bmatrix}x&y\\1&2\end{bmatrix}</span> </table> <h3>Static LaTeX rendering (<code>.mathquill-static-math</code>) tests</h3> @@ -212,6 +212,7 @@ <tr><td><span class="mathquill-static-math">\vec x + \tilde x + \vec A + \tilde A + \vec{abcd} + \tilde{abcd}</span><td><span>\vec x + \tilde x + \vec A + \tilde A + \vec{abcd} + \tilde{abcd}</span>^M <tr><td><span class="mathquill-static-math">\int _{\phi =0}^{2\pi }\int _{\theta =0}^{\pi }\int _{r=0}^{\infty }f(r,\theta ,\phi )r^2\sin \theta drd\theta d\phi </span><td><span>\int _{\phi =0}^{2\pi }\int _{\theta =0}^{\pi }\int _{r=0}^{\infty }f(r,\theta ,\phi )r^2\sin \theta drd\theta d\phi </span> <tr><td><span class="mathquill-static-math">\int_0^{\frac{\frac{1}{2}}{3}} \int_0^{\frac{1}{\frac{2}{3}}} \int_0^{\frac{1}{\frac{2}{\frac{3}{\frac{4}{5}}}}}</span><td><span>\int_0^{\frac{\frac{1}{2}}{3}} \int_0^{\frac{1}{\frac{2}{3}}} \int_0^{\frac{1}{\frac{2}{\frac{3}{\frac{4}{5}}}}}</span> +<tr><td><span class="mathquill-static-math">\begin{Vmatrix}x&y\\1&2\end{Vmatrix}</span><td><span>\begin{Vmatrix}x&y\\1&2\end{Vmatrix}</span> <tr><td colspan=2><span id="sixes"></span> <script> $(function() {