1 YUI.add('moodle-atto_table-button', function (Y, NAME) {
3 // This file is part of Moodle - http://moodle.org/
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 // GNU General Public License for more details.
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
20 * @copyright 2013 Damyon Wiese <damyon@moodle.com>
21 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 * @module moodle-atto_table-button
29 * Atto text editor table plugin.
31 * @namespace M.atto_table
33 * @extends M.editor_atto.EditorPlugin
36 var COMPONENT = 'atto_table',
38 '<form class="{{CSS.FORM}}">' +
39 '<label for="{{elementid}}_atto_table_caption">{{get_string "caption" component}}</label>' +
40 '<input class="{{CSS.CAPTION}} fullwidth" id="{{elementid}}_atto_table_caption" required />' +
43 '<label for="{{elementid}}_atto_table_headers" class="sameline">{{get_string "headers" component}}</label>' +
44 '<select class="{{CSS.HEADERS}}" id="{{elementid}}_atto_table_headers">' +
45 '<option value="columns">{{get_string "columns" component}}' + '</option>' +
46 '<option value="rows">{{get_string "rows" component}}' + '</option>' +
47 '<option value="both">{{get_string "both" component}}' + '</option>' +
50 '<div class="mdl-align">' +
52 '<button class="submit" type="submit">{{get_string "updatetable" component}}</button>' +
56 '<form class="{{CSS.FORM}}">' +
57 '<label for="{{elementid}}_atto_table_caption">{{get_string "caption" component}}</label>' +
58 '<input class="{{CSS.CAPTION}} fullwidth" id="{{elementid}}_atto_table_caption" required />' +
61 '<label for="{{elementid}}_atto_table_headers" class="sameline">{{get_string "headers" component}}</label>' +
62 '<select class="{{CSS.HEADERS}}" id="{{elementid}}_atto_table_headers">' +
63 '<option value="columns">{{get_string "columns" component}}' + '</option>' +
64 '<option value="rows">{{get_string "rows" component}}' + '</option>' +
65 '<option value="both">{{get_string "both" component}}' + '</option>' +
68 '<label for="{{elementid}}_atto_table_rows" class="sameline">{{get_string "numberofrows" component}}</label>' +
69 '<input class="{{CSS.ROWS}}" type="number" value="3" id="{{elementid}}_atto_table_rows" size="8" min="1" max="50"/>' +
71 '<label for="{{elementid}}_atto_table_columns" class="sameline">{{get_string "numberofcolumns" component}}</label>' +
72 '<input class="{{CSS.COLUMNS}}" type="number" value="3" id="{{elementid}}_atto_table_columns" size="8" min="1" max="20"/>' +
74 '<div class="mdl-align">' +
76 '<button class="{{CSS.SUBMIT}}" type="submit">{{get_string "createtable" component}}</button>' +
88 CAPTION: '.' + CSS.CAPTION,
89 HEADERS: '.' + CSS.HEADERS,
91 COLUMNS: '.' + CSS.COLUMNS,
92 SUBMIT: '.' + CSS.SUBMIT,
96 Y.namespace('M.atto_table').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
99 * A reference to the current selection at the time that the dialogue
102 * @property _currentSelection
106 _currentSelection: null,
109 * The contextual menu that we can open.
111 * @property _contextMenu
112 * @type M.editor_atto.Menu
118 * The last modified target.
120 * @property _lastTarget
127 * The list of menu items.
129 * @property _menuOptions
135 initializer: function() {
138 callback: this._displayTableEditor,
142 // Disable mozilla table controls.
144 document.execCommand("enableInlineTableEditing", false, false);
145 document.execCommand("enableObjectResizing", false, false);
150 * Display the table tool.
152 * @method _displayDialogue
155 _displayDialogue: function() {
156 // Store the current cursor position.
157 this._currentSelection = this.get('host').getSelection();
159 if (this._currentSelection !== false && (!this._currentSelection.collapsed)) {
160 var dialogue = this.getDialogue({
161 headerContent: M.util.get_string('createtable', COMPONENT),
162 focusAfterHide: true,
163 focusOnShowSelector: SELECTORS.CAPTION
166 // Set the dialogue content, and then show the dialogue.
167 dialogue.set('bodyContent', this._getDialogueContent())
173 * Display the appropriate table editor.
175 * If the current selection includes a table, then we show the
176 * contextual menu, otherwise show the table creation dialogue.
178 * @method _displayTableEditor
179 * @param {EventFacade} e
182 _displayTableEditor: function(e) {
183 var cell = this._getSuitableTableCell();
185 // Add the cell to the EventFacade to save duplication in when showing the menu.
187 return this._showTableMenu(e);
189 return this._displayDialogue(e);
193 * Returns whether or not the parameter node exists within the editor.
195 * @method _stopAtContentEditableFilter
198 * @return {boolean} whether or not the parameter node exists within the editor.
200 _stopAtContentEditableFilter: function(node) {
201 this.editor.contains(node);
205 * Return the edit table dialogue content, attaching any required
208 * @method _getEditDialogueContent
210 * @return {Node} The content to place in the dialogue.
212 _getEditDialogueContent: function() {
213 var template = Y.Handlebars.compile(EDITTEMPLATE);
214 this._content = Y.Node.create(template({
216 elementid: this.get('host').get('elementid'),
220 // Handle table setting.
221 this._content.one('.submit').on('click', this._updateTable, this);
223 return this._content;
227 * Return the dialogue content for the tool, attaching any required
230 * @method _getDialogueContent
232 * @return {Node} The content to place in the dialogue.
234 _getDialogueContent: function() {
235 var template = Y.Handlebars.compile(TEMPLATE);
236 this._content = Y.Node.create(template({
238 elementid: this.get('host').get('elementid'),
242 // Handle table setting.
243 this._content.one('.submit').on('click', this._setTable, this);
245 return this._content;
249 * Given the current selection, return a table cell suitable for table editing
250 * purposes, i.e. the first table cell selected, or the first cell in the table
251 * that the selection exists in, or null if not within a table.
253 * @method _getSuitableTableCell
255 * @return {Node} suitable target cell, or null if not within a table
257 _getSuitableTableCell: function() {
258 var targetcell = null,
259 host = this.get('host');
261 host.getSelectedNodes().some(function (node) {
262 if (node.ancestor('td, th, caption', true, this._stopAtContentEditableFilter)) {
265 var caption = node.ancestor('caption', true, this._stopAtContentEditableFilter);
267 var table = caption.get('parentNode');
269 targetcell = table.one('td, th');
273 // Once we've found a cell to target, we shouldn't need to keep looking.
279 var selection = host.getSelectionFromNode(targetcell);
280 host.setSelection(selection);
287 * Change a node from one type to another, copying all attributes and children.
289 * @method _changeNodeType
290 * @param {Y.Node} node
291 * @param {String} new node type
295 _changeNodeType: function(node, newType) {
296 var newNode = Y.Node.create('<' + newType + '></' + newType + '>');
297 newNode.setAttrs(node.getAttrs());
298 node.get('childNodes').each(function(child) {
299 newNode.append(child.remove());
301 node.replace(newNode);
306 * Handle updating an existing table.
308 * @method _updateTable
309 * @param {EventFacade} e
312 _updateTable: function(e) {
319 // Hide the dialogue.
324 // Add/update the caption.
325 caption = e.currentTarget.ancestor(SELECTORS.FORM).one(SELECTORS.CAPTION);
326 headers = e.currentTarget.ancestor(SELECTORS.FORM).one(SELECTORS.HEADERS);
328 table = this._lastTarget.ancestor('table');
330 captionnode = table.one('caption');
332 captionnode = Y.Node.create('<caption></caption');
333 table.insert(captionnode, 0);
335 captionnode.setHTML(caption.get('value'));
337 // Add the row headers.
338 if (headers.get('value') === 'rows' || headers.get('value') === 'both') {
339 table.all('tr').each(function (row) {
340 var cells = row.all('th, td'),
341 firstCell = cells.shift(),
344 if (firstCell.get('tagName') === 'TD') {
345 // Cell is a td but should be a th - change it.
346 newCell = this._changeNodeType(firstCell, 'th');
347 newCell.setAttribute('scope', 'row');
349 firstCell.setAttribute('scope', 'row');
352 // Now make sure all other cells in the row are td.
353 cells.each(function (cell) {
354 if (cell.get('tagName') === 'TH') {
355 newCell = this._changeNodeType(cell, 'td');
356 newCell.removeAttribute('scope');
362 // Add the col headers. These may overrule the row headers in the first cell.
363 if (headers.get('value') === 'columns' || headers.get('value') === 'both') {
364 var rows = table.all('tr'),
365 firstRow = rows.shift(),
368 firstRow.all('td, th').each(function (cell) {
369 if (cell.get('tagName') === 'TD') {
370 // Cell is a td but should be a th - change it.
371 newCell = this._changeNodeType(cell, 'th');
372 newCell.setAttribute('scope', 'col');
374 cell.setAttribute('scope', 'col');
377 // Change all the cells in the rest of the table to tds (unless they are row headers).
378 rows.each(function(row) {
379 var cells = row.all('th, td');
381 if (headers.get('value') === 'both') {
382 // Ignore the first cell because it's a row header.
385 cells.each(function(cell) {
386 if (cell.get('tagName') === 'TH') {
387 newCell = this._changeNodeType(cell, 'td');
388 newCell.removeAttribute('scope');
399 * Handle creation of a new table.
402 * @param {EventFacade} e
405 _setTable: function(e) {
415 // Hide the dialogue.
420 caption = e.currentTarget.ancestor(SELECTORS.FORM).one(SELECTORS.CAPTION);
421 rows = e.currentTarget.ancestor(SELECTORS.FORM).one(SELECTORS.ROWS);
422 cols = e.currentTarget.ancestor(SELECTORS.FORM).one(SELECTORS.COLUMNS);
423 headers = e.currentTarget.ancestor(SELECTORS.FORM).one(SELECTORS.HEADERS);
425 // Set the selection.
426 this.get('host').setSelection(this._currentSelection);
428 // Note there are some spaces inserted in the cells and before and after, so that users have somewhere to click.
430 tablehtml = '<br/>' + nl + '<table>' + nl;
431 tablehtml += '<caption>' + Y.Escape.html(caption.get('value')) + '</caption>' + nl;
434 if (headers.get('value') === 'columns' || headers.get('value') === 'both') {
436 tablehtml += '<thead>' + nl + '<tr>' + nl;
437 for (j = 0; j < parseInt(cols.get('value'), 10); j++) {
438 tablehtml += '<th scope="col"></th>' + nl;
440 tablehtml += '</tr>' + nl + '</thead>' + nl;
442 tablehtml += '<tbody>' + nl;
443 for (; i < parseInt(rows.get('value'), 10); i++) {
444 tablehtml += '<tr>' + nl;
445 for (j = 0; j < parseInt(cols.get('value'), 10); j++) {
446 if (j === 0 && (headers.get('value') === 'rows' || headers.get('value') === 'both')) {
447 tablehtml += '<th scope="row"></th>' + nl;
449 tablehtml += '<td></td>' + nl;
452 tablehtml += '</tr>' + nl;
454 tablehtml += '</tbody>' + nl;
455 tablehtml += '</table>' + nl + '<br/>';
457 this.get('host').insertContentAtFocusPoint(tablehtml);
459 // Mark the content as updated.
464 * Search for all the cells in the current, next and previous columns.
466 * @method _findColumnCells
468 * @return {Object} containing current, prev and next {Y.NodeList}s
470 _findColumnCells: function() {
471 var columnindex = this._getColumnIndex(this._lastTarget),
472 rows = this._lastTarget.ancestor('table').all('tr'),
473 currentcells = new Y.NodeList(),
474 prevcells = new Y.NodeList(),
475 nextcells = new Y.NodeList();
477 rows.each(function(row) {
478 var cells = row.all('td, th'),
479 cell = cells.item(columnindex),
480 cellprev = cells.item(columnindex-1),
481 cellnext = cells.item(columnindex+1);
482 currentcells.push(cell);
484 prevcells.push(cellprev);
487 nextcells.push(cellnext);
492 current: currentcells,
499 * Hide the entries in the context menu that don't make sense with the
502 * @method _hideInvalidEntries
503 * @param {Y.Node} node - The node containing the menu.
506 _hideInvalidEntries: function(node) {
508 var table = this._lastTarget.ancestor('table'),
509 row = this._lastTarget.ancestor('tr'),
510 rows = table.all('tr'),
511 rowindex = rows.indexOf(row),
512 prevrow = rows.item(rowindex - 1),
513 prevrowhascells = prevrow ? prevrow.one('td') : null;
515 if (!row || !prevrowhascells) {
516 node.one('[data-change="moverowup"]').hide();
518 node.one('[data-change="moverowup"]').show();
521 var nextrow = rows.item(rowindex + 1),
522 rowhascell = row ? row.one('td') : false;
524 if (!row || !nextrow || !rowhascell) {
525 node.one('[data-change="moverowdown"]').hide();
527 node.one('[data-change="moverowdown"]').show();
531 var cells = this._findColumnCells();
532 if (cells.prev.filter('td').size() > 0) {
533 node.one('[data-change="movecolumnleft"]').show();
535 node.one('[data-change="movecolumnleft"]').hide();
538 var colhascell = cells.current.filter('td').size() > 0;
539 if ((cells.next.size() > 0) && colhascell) {
540 node.one('[data-change="movecolumnright"]').show();
542 node.one('[data-change="movecolumnright"]').hide();
546 if (cells.current.filter('td').size() > 0) {
547 node.one('[data-change="deletecolumn"]').show();
549 node.one('[data-change="deletecolumn"]').hide();
552 if (!row || !row.one('td')) {
553 node.one('[data-change="deleterow"]').hide();
555 node.one('[data-change="deleterow"]').show();
560 * Display the table menu.
562 * @method _showTableMenu
563 * @param {EventFacade} e
566 _showTableMenu: function(e) {
571 if (!this._contextMenu) {
572 this._menuOptions = [
574 text: M.util.get_string("addcolumnafter", COMPONENT),
576 change: "addcolumnafter"
579 text: M.util.get_string("addrowafter", COMPONENT),
581 change: "addrowafter"
584 text: M.util.get_string("moverowup", COMPONENT),
589 text: M.util.get_string("moverowdown", COMPONENT),
591 change: "moverowdown"
594 text: M.util.get_string("movecolumnleft", COMPONENT),
596 change: "movecolumnleft"
599 text: M.util.get_string("movecolumnright", COMPONENT),
601 change: "movecolumnright"
604 text: M.util.get_string("deleterow", COMPONENT),
609 text: M.util.get_string("deletecolumn", COMPONENT),
611 change: "deletecolumn"
614 text: M.util.get_string("edittable", COMPONENT),
621 this._contextMenu = new Y.M.editor_atto.Menu({
622 items: this._menuOptions
625 // Add event handlers for table control menus.
626 boundingBox = this._contextMenu.get('boundingBox');
627 boundingBox.delegate('click', this._handleTableChange, 'a', this);
630 boundingBox = this._contextMenu.get('boundingBox');
632 // We store the cell of the last click (the control node is transient).
633 this._lastTarget = e.tableCell.ancestor('.editor_atto_content td, .editor_atto_content th', true);
635 this._hideInvalidEntries(boundingBox);
637 // Clear the focusAfterHide for any other menus which may be open.
638 Y.Array.each(this.get('host').openMenus, function(menu) {
639 menu.set('focusAfterHide', null);
642 // Ensure that we focus on the button in the toolbar when we tab back to the menu.
643 var creatorButton = this.buttons[this.name];
644 this.get('host')._setTabFocus(creatorButton);
646 // Show the context menu, and align to the current position.
647 this._contextMenu.show();
648 this._contextMenu.align(this.buttons.table, [Y.WidgetPositionAlign.TL, Y.WidgetPositionAlign.BL]);
649 this._contextMenu.set('focusAfterHide', creatorButton);
651 // If there are any anchors in the bounding box, focus on the first.
652 if (boundingBox.one('a')) {
653 boundingBox.one('a').focus();
656 // Add this menu to the list of open menus.
657 this.get('host').openMenus = [this._contextMenu];
661 * Handle a selection from the table control menu.
663 * @method _handleTableChange
664 * @param {EventFacade} e
667 _handleTableChange: function(e) {
670 this._contextMenu.set('focusAfterHide', this.get('host').editor);
671 // Hide the context menu.
672 this._contextMenu.hide(e);
675 switch (e.target.getData('change')) {
676 case 'addcolumnafter':
677 this._addColumnAfter();
686 this._deleteColumn();
697 case 'movecolumnleft':
698 this._moveColumnLeft();
700 case 'movecolumnright':
701 this._moveColumnRight();
707 * Determine the index of a row in a table column.
709 * @method _getRowIndex
713 _getRowIndex: function(cell) {
714 var tablenode = cell.ancestor('table'),
715 rownode = cell.ancestor('tr');
717 if (!tablenode || !rownode) {
721 var rows = tablenode.all('tr');
723 return rows.indexOf(rownode);
727 * Determine the index of a column in a table row.
729 * @method _getColumnIndex
730 * @param {Node} cellnode
733 _getColumnIndex: function(cellnode) {
734 var rownode = cellnode.ancestor('tr');
740 var cells = rownode.all('td, th');
742 return cells.indexOf(cellnode);
746 * Delete the current row.
751 _deleteRow: function() {
752 var row = this._lastTarget.ancestor('tr');
754 if (row && row.one('td')) {
755 // Only delete rows with at least one non-header cell.
769 _moveRowUp: function() {
770 var row = this._lastTarget.ancestor('tr'),
771 prevrow = row.previous('tr');
772 if (!row || !prevrow) {
784 * @method _moveColumnLeft
787 _moveColumnLeft: function() {
788 var cells = this._findColumnCells();
790 if (cells.current.size() > 0 && cells.prev.size() > 0 && cells.current.size() === cells.prev.size()) {
792 for (i = 0; i < cells.current.size(); i++) {
793 var cell = cells.current.item(i),
794 prevcell = cells.prev.item(i);
804 * Add a caption to the table if it doesn't have one.
806 * @method _addCaption
809 _addCaption: function() {
810 var table = this._lastTarget.ancestor('table'),
811 caption = table.one('caption');
814 table.insert(Y.Node.create('<caption> </caption>'), 1);
819 * Remove a caption from the table if has one.
821 * @method _removeCaption
824 _removeCaption: function() {
825 var table = this._lastTarget.ancestor('table'),
826 caption = table.one('caption');
829 caption.remove(true);
836 * @method _moveColumnRight
839 _moveColumnRight: function() {
840 var cells = this._findColumnCells();
842 // Check we have some tds in this column, and one exists to the right.
843 if ( (cells.next.size() > 0) &&
844 (cells.current.size() === cells.next.size()) &&
845 (cells.current.filter('td').size() > 0)) {
847 for (i = 0; i < cells.current.size(); i++) {
848 var cell = cells.current.item(i),
849 nextcell = cells.next.item(i);
861 * @method _moveRowDown
864 _moveRowDown: function() {
865 var row = this._lastTarget.ancestor('tr'),
866 nextrow = row.next('tr');
867 if (!row || !nextrow || !row.one('td')) {
877 * Edit table (show the dialogue).
882 _editTable: function() {
883 var dialogue = this.getDialogue({
884 headerContent: M.util.get_string('edittable', COMPONENT),
885 focusAfterHide: false,
886 focusOnShowSelector: SELECTORS.CAPTION
889 // Set the dialogue content, and then show the dialogue.
890 var node = this._getEditDialogueContent(),
891 captioninput = node.one(SELECTORS.CAPTION),
892 headersinput = node.one(SELECTORS.HEADERS),
893 table = this._lastTarget.ancestor('table'),
894 captionnode = table.one('caption');
897 captioninput.set('value', captionnode.getHTML());
899 captioninput.set('value', '');
902 var headersvalue = 'columns';
903 if (table.one('th[scope="row"]')) {
904 headersvalue = 'rows';
905 if (table.one('th[scope="col"]')) {
906 headersvalue = 'both';
909 headersinput.set('value', headersvalue);
910 dialogue.set('bodyContent', node).show();
915 * Delete the current column.
917 * @method _deleteColumn
920 _deleteColumn: function() {
921 var columnindex = this._getColumnIndex(this._lastTarget),
922 table = this._lastTarget.ancestor('table'),
923 rows = table.all('tr'),
924 columncells = new Y.NodeList(),
927 rows.each(function(row) {
928 var cells = row.all('td, th');
929 var cell = cells.item(columnindex);
930 if (cell.get('tagName') === 'TD') {
933 columncells.push(cell);
936 // Do not delete all the headers.
938 columncells.remove(true);
946 * Add a row after the current row.
948 * @method _addRowAfter
951 _addRowAfter: function() {
952 var rowindex = this._getRowIndex(this._lastTarget);
954 var tablebody = this._lastTarget.ancestor('table').one('tbody');
956 // Not all tables have tbody.
957 tablebody = this._lastTarget.ancestor('table');
961 var firstrow = tablebody.one('tr');
963 firstrow = this._lastTarget.ancestor('table').one('tr');
966 // Table has no rows. Boo.
969 newrow = firstrow.cloneNode(true);
970 newrow.all('th, td').each(function (tablecell) {
971 if (tablecell.get('tagName') === 'TH') {
972 if (tablecell.getAttribute('scope') !== 'row') {
973 var newcell = Y.Node.create('<td></td>');
974 tablecell.replace(newcell);
978 tablecell.setHTML(' ');
981 tablebody.insert(newrow, rowindex);
988 * Add a column after the current column.
990 * @method _addColumnAfter
993 _addColumnAfter: function() {
994 var cells = this._findColumnCells(),
996 clonecells = cells.next;
997 if (cells.next.size() <= 0) {
999 clonecells = cells.current;
1002 Y.each(clonecells, function(cell) {
1003 var newcell = cell.cloneNode();
1004 // Clear the content of the cell.
1005 newcell.setHTML(' ');
1008 cell.get('parentNode').insert(newcell, cell);
1010 cell.get('parentNode').insert(newcell, cell);
1022 }, '@VERSION@', {"requires": ["moodle-editor_atto-plugin", "moodle-editor_atto-menu", "event", "event-valuechange"]});