PluginProbe
TablePress – Tables in WordPress made easy / 2.2.5
TablePress – Tables in WordPress made easy v2.2.5
3.3.4 3.3.3 3.3.2 3.3.1 trunk 1.12 1.14 1.9.2 2.0.4 2.1.7 2.1.8 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3 2.3.1 2.3.2 2.4 2.4.1 2.4.2 2.4.3 2.4.4 All 44 releases
tablepress / admin / js / edit.js

edit.js in TablePress – Tables in WordPress made easy 2.2.5, at admin/js/edit.js

1,617 lines 61.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * JavaScript code for the "Edit" screen.
3 *
4 * @package TablePress
5 * @subpackage Views JavaScript
6 * @author Tobias Bäthge
7 * @since 2.0.0
8 */
9
10 /* globals tp, wp, ajaxurl, JSON, jspreadsheet, jexcel, wpLink, jQuery */
11 /* eslint-disable jsdoc/check-param-names, jsdoc/valid-types */
12
13 /**
14 * WordPress dependencies.
15 */
16 import { __, _x, sprintf } from '@wordpress/i18n';
17 import { doAction as do_action, applyFilters as apply_filters } from '@wordpress/hooks';
18 import { buildQueryString } from '@wordpress/url';
19
20 /**
21 * Internal dependencies.
22 */
23 import { $ } from './common/functions';
24 import contextMenu from './edit/contextmenu';
25 import naturalSort from './edit/naturalsort';
26
27 // Ensure the global `tp` object exists.
28 window.tp = window.tp || {};
29
30 tp.made_changes = false;
31
32 tp.helpers = tp.helpers || {};
33 tp.callbacks = tp.callbacks || {};
34
35 // Initial selection: cell A1.
36 tp.helpers.selection = tp.helpers.selection || {
37 rows: [ 0 ],
38 columns: [ 0 ],
39 };
40
41 tp.helpers.unsaved_changes = tp.helpers.unsaved_changes || {};
42
43 /**
44 * [unsaved_changes.unload_dialog description]
45 *
46 * @param {Event} event [description]
47 */
48 tp.helpers.unsaved_changes.unload_dialog = function ( event ) {
49 event.preventDefault(); // Cancel the event as stated by the standard.
50 event.returnValue = ''; // Chrome requires returnValue to be set.
51 };
52
53 /**
54 * [unsaved_changes.set description]
55 */
56 tp.helpers.unsaved_changes.set = function () {
57 // Bail early if this function was already called.
58 if ( tp.made_changes ) {
59 return;
60 }
61 tp.made_changes = true;
62 window.addEventListener( 'beforeunload', tp.helpers.unsaved_changes.unload_dialog );
63 };
64
65 /**
66 * [unsaved_changes.unset description]
67 */
68 tp.helpers.unsaved_changes.unset = function () {
69 tp.made_changes = false;
70 window.removeEventListener( 'beforeunload', tp.helpers.unsaved_changes.unload_dialog );
71 };
72
73 tp.helpers.options = tp.helpers.options || {};
74
75 /**
76 * Loads table options and sets DOM element states appropriately.
77 */
78 tp.helpers.options.load = function () {
79 Object.keys( tp.table.options ).forEach( function ( option_name ) {
80 // Skip entries that are not actually option fields.
81 if ( 'last_editor' === option_name ) {
82 return;
83 }
84
85 // Allow skipping options, e.g. when custom loading is used.
86 option_name = apply_filters( 'tablepress.optionsLoad', option_name );
87 if ( '' === option_name ) {
88 return;
89 }
90
91 let $field = $( `#option-${ option_name }` );
92 if ( ! $field ) {
93 // If no field with just that option_name is found, it could be a radio button, which have IDs based on option_name and value.
94 $field = $( `#option-${ option_name }-${ tp.table.options[ option_name ] }` );
95 }
96 if ( ! $field ) {
97 // If there's still no field, the field might be missing. For example, the "Custom Commands" only exists if a user is allowed to use `unfiltered_html`.
98 return;
99 }
100
101 if ( $field instanceof HTMLInputElement && 'checkbox' === $field.type ) {
102 // For checkboxes, the `checked` state is based on the value (true/false).
103 $field.checked = tp.table.options[ option_name ];
104 } else if ( $field instanceof HTMLInputElement && 'radio' === $field.type ) {
105 // For checkboxes, the `checked` state is true, as only the field corresponding to the value is selected.
106 $field.checked = true;
107 } else {
108 // For all other fields, the form field value is set according to the option value.
109 $field.value = tp.table.options[ option_name ];
110 }
111 } );
112
113 // Turn off "Enable Visitor Features" if the table has merged cells.
114 if ( tp.table.options.use_datatables && tp.helpers.editor.has_merged_cells() ) {
115 tp.table.options.use_datatables = false;
116 $( '#option-use_datatables' ).checked = false;
117 }
118
119 tp.helpers.options.check_dependencies();
120 };
121
122 /**
123 * Sets the table option property when the DOM element (form field) is changed.
124 *
125 * @param {Event} event [description]
126 */
127 tp.helpers.options.change = function ( event ) {
128 if ( ! event.target ) {
129 return;
130 }
131
132 const option_name = event.target.name || '';
133
134 // Skip input fields that don't have a valid `name` attribute, as these don't directly reflect table options.
135 if ( '' === option_name ) {
136 return;
137 }
138
139 const property = ( event.target instanceof HTMLInputElement && 'checkbox' === event.target.type ) ? 'checked' : 'value';
140 tp.table.options[ option_name ] = event.target[ property ];
141
142 // Save numeric options as numbers.
143 if ( event.target instanceof HTMLInputElement && 'number' === event.target.type ) {
144 tp.table.options[ option_name ] = parseInt( tp.table.options[ option_name ], 10 );
145 }
146
147 // Turn off "Enable Visitor Features" if the table has merged cells.
148 if ( 'use_datatables' === option_name && tp.table.options.use_datatables && tp.helpers.editor.has_merged_cells() ) {
149 tp.table.options.use_datatables = false;
150 $( '#option-use_datatables' ).checked = false;
151 window.alert( __( 'You can not enable the Table Features for Site Visitors, because your table contains combined/merged cells.', 'tablepress' ) );
152 }
153
154 do_action( 'tablepress.optionsChange', option_name, property, event );
155
156 tp.helpers.options.check_dependencies();
157 tp.helpers.unsaved_changes.set();
158 tp.editor.updateTable(); // Redraw table.
159 };
160
161 /**
162 * Checks dependencies of options and sets DOM state ("disabled") appropriately.
163 */
164 tp.helpers.options.check_dependencies = function () {
165 $( '#option-use_datatables' ).disabled = ! tp.table.options.table_head;
166 $( '#notice-datatables-head-row' ).style.display = tp.table.options.table_head ? 'none' : 'block';
167
168 $( '#option-print_name_position' ).disabled = ! tp.table.options.print_name;
169 $( '#option-print_description_position' ).disabled = ! tp.table.options.print_description;
170
171 const js_features_enabled = ( tp.table.options.use_datatables && tp.table.options.table_head );
172 $( '#tablepress_edit-datatables-features' ).querySelectorAll( ':scope input:not(#option-use_datatables), :scope textarea' ).forEach( ( $field ) => ( $field.disabled = ! js_features_enabled ) );
173
174 const pagination_enabled = ( js_features_enabled && tp.table.options.datatables_paginate );
175 $( '#option-datatables_lengthchange' ).disabled = ! pagination_enabled;
176 $( '#option-datatables_paginate_entries' ).disabled = ! pagination_enabled;
177
178 do_action( 'tablepress.optionsCheckDependencies' );
179 };
180
181 /**
182 * Validate certain form fields, before saving or generating a preview.
183 */
184 tp.helpers.options.validate_fields = function () {
185 // The pagination entries value must be a positive number.
186 if ( tp.table.options.datatables_paginate && ( isNaN( tp.table.options.datatables_paginate_entries ) || tp.table.options.datatables_paginate_entries < 1 || tp.table.options.datatables_paginate_entries > 9999 ) ) {
187 window.alert( sprintf( __( 'The entered value in the “%1$s” field is invalid.', 'tablepress' ), __( 'Pagination Entries', 'tablepress' ) ) );
188 const $field = $( '#option-datatables_paginate_entries' );
189 $field.focus();
190 $field.select();
191 return false;
192 }
193
194 // The "Extra CSS classes" must not contain invalid characters.
195 if ( ( /[^A-Za-z0-9- _:]/ ).test( tp.table.options.extra_css_classes ) ) {
196 window.alert( sprintf( __( 'The entered value in the “%1$s” field is invalid.', 'tablepress' ), __( 'Extra CSS Classes', 'tablepress' ) ) );
197 const $field = $( '#option-extra_css_classes' );
198 $field.focus();
199 $field.select();
200 return false;
201 }
202
203 return apply_filters( 'tablepress.optionsValidateFields', true );
204 };
205
206 tp.helpers.visibility = tp.helpers.visibility || {};
207
208 /**
209 * [visibility.load description]
210 */
211 tp.helpers.visibility.load = function () {
212 const num_rows = tp.table.visibility.rows.length;
213 const num_columns = tp.table.visibility.columns.length;
214 const meta = {};
215 // Collect meta data for hidden rows.
216 for ( let row_idx = 0; row_idx < num_rows; row_idx++ ) {
217 if ( 1 === tp.table.visibility.rows[ row_idx ] ) {
218 continue;
219 }
220 for ( let col_idx = 0; col_idx < num_columns; col_idx++ ) {
221 const cell_name = jspreadsheet.getColumnNameFromId( [ col_idx, row_idx ] );
222 meta[ cell_name ] = meta[ cell_name ] || {};
223 meta[ cell_name ].row_hidden = true;
224 }
225 }
226 // Collect meta data for hidden columns.
227 for ( let col_idx = 0; col_idx < num_columns; col_idx++ ) {
228 if ( 1 === tp.table.visibility.columns[ col_idx ] ) {
229 continue;
230 }
231 for ( let row_idx = 0; row_idx < num_rows; row_idx++ ) {
232 const cell_name = jspreadsheet.getColumnNameFromId( [ col_idx, row_idx ] );
233 meta[ cell_name ] = meta[ cell_name ] || {};
234 meta[ cell_name ].column_hidden = true;
235 }
236 }
237 return meta;
238 };
239
240 /**
241 * [visibility.update description]
242 */
243 tp.helpers.visibility.update = function () {
244 // Set all rows and columns to visible first.
245 tp.table.visibility.rows = [];
246 for ( let row_idx = 0; row_idx < tp.editor.options.data.length; row_idx++ ) {
247 tp.table.visibility.rows[ row_idx ] = 1;
248 }
249 tp.table.visibility.columns = [];
250 for ( let col_idx = 0; col_idx < tp.editor.options.columns.length; col_idx++ ) {
251 tp.table.visibility.columns[ col_idx ] = 1;
252 }
253 // Get all hidden cells and mark their rows/columns as hidden.
254 Object.keys( tp.editor.options.meta ).forEach( function ( cell_name ) {
255 const cell = jspreadsheet.getIdFromColumnName( cell_name, true ); // Returns [ col_idx, row_idx ].
256 if ( 1 === tp.table.visibility.rows[ cell[1] ] && tp.editor.options.meta[ cell_name ].row_hidden ) {
257 tp.table.visibility.rows[ cell[1] ] = 0;
258 }
259 if ( 1 === tp.table.visibility.columns[ cell[0] ] && tp.editor.options.meta[ cell_name ].column_hidden ) {
260 tp.table.visibility.columns[ cell[0] ] = 0;
261 }
262 } );
263 };
264
265 /**
266 * Check whether the Hide or Unhide entries in the context menu should be disabled, by comparing
267 * whether any of the selected rows/columns have a different visibility state than what the entry would set.
268 *
269 * @param {string} type What to hide or unhide ("rows" or "columns").
270 * @param {boolean} visibility 0 for hidden, 1 for visible.
271 * @return {boolean} True if the entry shall be shown, false if not.
272 */
273 tp.helpers.visibility.selection_contains = function ( type, visibility ) {
274 // Show the entry as soon as one of the selected rows/columns does not have the intended visibility state.
275 return tp.helpers.selection[ type ].some( ( roc_idx ) => ( tp.table.visibility[ type ][ roc_idx ] === visibility ) );
276 };
277
278 /**
279 * For the context menu and button, determine whether moving the rows/columns of the current selection is allowed.
280 *
281 * @param {[type]} type [description]
282 * @param {[type]} direction [description]
283 * @return {boolean} Whether the move is allowed or not.
284 */
285 tp.helpers.move_allowed = function ( type, direction ) {
286 // When moving up or left, or to top or first, test the first row/column of the selected range.
287 let roc_to_test = tp.helpers.selection[ type ][0];
288 let min_max_roc = 0; // First row/column.
289 // When moving down or right, or bottom or last, test the last row/column of the selected range.
290 if ( 'down' === direction || 'right' === direction || 'bottom' === direction || 'last' === direction ) {
291 roc_to_test = tp.helpers.selection[ type ][ tp.helpers.selection[ type ].length - 1 ];
292 min_max_roc = ( 'rows' === type ) ? tp.editor.options.data.length - 1 : tp.editor.options.columns.length - 1;
293 }
294 // Moving is disallowed if the first/last row/column is already at the target edge.
295 if ( min_max_roc === roc_to_test ) {
296 return false;
297 }
298 // Otherwise allow the move.
299 return true;
300 };
301
302 /**
303 * For the context menu and button, determine whether merging the current selection is allowed.
304 *
305 * @param {string} errors Whether errors should also be alert()ed.
306 * @param {Object} error_message Call-by-reference object for the error message.
307 * @return {boolean} Whether the merge is allowed or not.
308 */
309 tp.helpers.cell_merge_allowed = function ( errors, error_message = {} ) {
310 const alert_on_error = ( 'alert' === errors );
311
312 // If the "Table Head Row" and Enable Visitor Features" options are enabled, disable merging cells.
313 if ( tp.table.options.table_head && tp.table.options.use_datatables ) {
314 error_message.text = sprintf( __( 'You can not combine these cells, because the “%1$s” checkbox in the “%2$s” section is checked.', 'tablepress' ), __( 'Enable Visitor Features', 'tablepress' ), __( 'Table Features for Site Visitors', 'tablepress' ) ) +
315 ' ' + __( 'The Table Features for Site Visitors are not compatible with merged cells.', 'tablepress' );
316 if ( alert_on_error ) {
317 window.alert( error_message.text );
318 }
319 return false;
320 }
321
322 const first_selected_row = tp.helpers.selection.rows[0];
323 const last_selected_row = tp.helpers.selection.rows[ tp.helpers.selection.rows.length - 1 ];
324
325 // If the head row option is enabled, and the first and (at least) second row are selected, disable merging cells.
326 if ( tp.table.options.table_head && 0 === first_selected_row && last_selected_row > 0 ) {
327 error_message.text = sprintf( __( 'You can not combine these cells, because the “%1$s” checkbox in the “%2$s” section is checked.', 'tablepress' ), __( 'Table Head Row', 'tablepress' ), __( 'Table Options', 'tablepress' ) );
328 if ( alert_on_error ) {
329 window.alert( error_message.text );
330 }
331 return false;
332 }
333
334 // If the foot row option is enabled, and the last and (at least) next to last row are selected, disable merging cells.
335 const last_row_idx = tp.editor.options.data.length - 1;
336 if ( tp.table.options.table_foot && last_row_idx === last_selected_row && first_selected_row < last_row_idx ) {
337 error_message.text = sprintf( __( 'You can not combine these cells, because the “%1$s” checkbox in the “%2$s” section is checked.', 'tablepress' ), __( 'Table Foot Row', 'tablepress' ), __( 'Table Options', 'tablepress' ) );
338 if ( alert_on_error ) {
339 window.alert( error_message.text );
340 }
341 return false;
342 }
343
344 // Otherwise allow the merge.
345 return true;
346 };
347
348 tp.helpers.editor = tp.helpers.editor || {};
349
350 /**
351 * [editor_reselect description]
352 *
353 * @param {[type]} el [description]
354 * @param {[type]} obj Jspreadsheet instance, passed e.g. by onblur. If not present, we use tp.editor.
355 */
356 tp.helpers.editor.reselect = function ( el, obj ) {
357 if ( 'undefined' === typeof obj ) {
358 obj = tp.editor;
359 }
360 obj.updateSelectionFromCoords(
361 tp.helpers.selection.columns[0],
362 tp.helpers.selection.rows[0],
363 tp.helpers.selection.columns[ tp.helpers.selection.columns.length - 1 ],
364 tp.helpers.selection.rows[ tp.helpers.selection.rows.length - 1 ]
365 );
366 };
367
368 /**
369 * [editor_has_merged_cells description]
370 */
371 tp.helpers.editor.has_merged_cells = function () {
372 const num_rows = tp.editor.options.data.length;
373 const num_columns = tp.editor.options.columns.length;
374 for ( let row_idx = 1; row_idx < num_rows; row_idx++ ) {
375 for ( let col_idx = 1; col_idx < num_columns; col_idx++ ) {
376 if ( '#rowspan#' === tp.editor.options.data[ row_idx ][ col_idx ] || '#colspan#' === tp.editor.options.data[ row_idx ][ col_idx ] ) {
377 return true;
378 }
379 }
380 }
381 return false;
382 };
383
384 /**
385 * Creates the sorting function that is used when sorting the table by a column.
386 *
387 * @param {number} direction Sorting direction. 0 for ascending, 1 for descending.
388 * @return {Function} Sorting function.
389 */
390 tp.helpers.editor.sorting = function( direction ) {
391 direction = direction ? -1 : 1;
392 return function( a, b ) {
393 // The actual value is stored in the second array element, the first contains the row index.
394 return direction * naturalSort( a[1], b[1] );
395 };
396 };
397
398 tp.callbacks.editor = tp.callbacks.editor || {};
399
400 /**
401 * [editor_onselection description]
402 *
403 * @param {[type]} instance [description]
404 * @param {[type]} x1 [description]
405 * @param {[type]} y1 [description]
406 * @param {[type]} x2 [description]
407 * @param {[type]} y2 [description]
408 * @param {[type]} origin [description]
409 */
410 tp.callbacks.editor.onselection = function ( instance, x1, y1, x2, y2 /*, origin */ ) {
411 tp.helpers.selection = {
412 rows: [],
413 columns: [],
414 };
415 for ( let row_idx = y1; row_idx <= y2; row_idx++ ) {
416 tp.helpers.selection.rows.push( row_idx );
417 }
418 for ( let col_idx = x1; col_idx <= x2; col_idx++ ) {
419 tp.helpers.selection.columns.push( col_idx );
420 }
421 };
422
423 /**
424 * [editor_onupdatetable description]
425 *
426 * @param {[type]} instance [description]
427 * @param {[type]} cell [description]
428 * @param {[type]} col_idx [description]
429 * @param {[type]} row_idx [description]
430 * @param {[type]} value [description]
431 * @param {[type]} label [description]
432 * @param {[type]} cell_name [description]
433 */
434 tp.callbacks.editor.onupdatetable = function ( instance, cell, col_idx, row_idx, value, label, cell_name ) {
435 const meta = instance.jspreadsheet.options.meta[ cell_name ];
436
437 // Add class to cells (td) of hidden columns.
438 cell.classList.toggle( 'column-hidden', Boolean( meta?.column_hidden ) );
439
440 // Add classes to row (tr) for hidden rows and head/foot row. Only needs to be done once per row, thus when processing the first column.
441 if ( 0 === col_idx ) {
442 cell.parentNode.classList.toggle( 'row-hidden', Boolean( meta?.row_hidden ) );
443 cell.parentNode.classList.remove( 'head-row', 'foot-row' );
444
445 // After processing the last row, potentially add classes to the head and foot rows.
446 if ( row_idx === instance.jspreadsheet.rows.length - 1 ) {
447 const visible_rows = instance.jspreadsheet.content.querySelectorAll( ':scope tbody tr:not(.row-hidden)' );
448 // Designating a head and a foot row only makes sense for tables with more than one row. Single-row tables will only have a table body.
449 if ( 1 < visible_rows.length ) {
450 if ( tp.table.options.table_head ) {
451 visible_rows[0].classList.add( 'head-row' );
452 }
453 if ( tp.table.options.table_foot ) {
454 visible_rows[ visible_rows.length - 1 ].classList.add( 'foot-row' );
455 }
456 }
457 }
458 }
459 };
460
461 /**
462 * [editor_oninsertroc description]
463 *
464 * Abbreviations:
465 * roc: row or column
466 * cor: column or row
467 *
468 * @param {[type]} type [description]
469 * @param {[type]} action [description]
470 * @param {[type]} el [description]
471 * @param {[type]} roc_idx [description]
472 * @param {[type]} num_rocs [description]
473 * @param {[type]} roc_records [description]
474 * @param {[type]} insertBefore [description]
475 */
476 tp.callbacks.editor.oninsertroc = function ( type, action, el, roc_idx, num_rocs, roc_records, insertBefore ) {
477 const handling_rows = ( 'rows' === type );
478 const property = handling_rows ? 'column_hidden' : 'row_hidden';
479 const duplicating = ( 'duplicate' === action );
480
481 const from_roc_idx = roc_idx + ( insertBefore ? num_rocs : 0 );
482 const num_cors = handling_rows ? tp.editor.options.columns.length : tp.editor.options.data.length;
483
484 // Get data of row/column that is copied.
485 const from_meta = {};
486 for ( let cor_idx = 0; cor_idx < num_cors; cor_idx++ ) {
487 const cell_idx = handling_rows ? [ cor_idx, from_roc_idx ] : [ from_roc_idx, cor_idx ];
488 const meta = tp.editor.options.meta[ jspreadsheet.getColumnNameFromId( cell_idx ) ];
489 if ( ! meta ) {
490 continue;
491 }
492 // When duplicating, copy full cell meta, otherwise only the necessary property (row visibility for columns, column visibility for rows).
493 if ( duplicating ) {
494 from_meta[ cor_idx ] = meta;
495 } else if ( meta[ property ] ) {
496 from_meta[ cor_idx ] = from_meta[ cor_idx ] || {};
497 from_meta[ cor_idx ][ property ] = true;
498 }
499 }
500
501 const from_meta_keys = Object.keys( from_meta );
502 // Bail early if there's nothing to copy.
503 if ( ! from_meta_keys.length ) {
504 return;
505 }
506
507 // Construct meta data for target rows/columns.
508 const to_meta = {};
509 if ( ! insertBefore ) {
510 roc_idx++; // When appending (i.e. insert after), we start after the current row or column.
511 }
512 for ( let new_roc = 0; new_roc < num_rocs; new_roc++ ) {
513 const to_roc_idx = roc_idx + new_roc;
514 from_meta_keys.forEach( function ( cor_idx ) {
515 const cell_idx = handling_rows ? [ cor_idx, to_roc_idx ] : [ to_roc_idx, cor_idx ];
516 to_meta[ jspreadsheet.getColumnNameFromId( cell_idx ) ] = from_meta[ cor_idx ];
517 } );
518 }
519
520 tp.editor.setMeta( to_meta );
521 tp.editor.updateTable(); // Redraw table.
522 };
523
524 /**
525 * [editor_onmove description]
526 *
527 * @param {[type]} el [description]
528 * @param {[type]} old_roc_idx [description]
529 * @param {[type]} new_roc_idx [description]
530 */
531 tp.callbacks.editor.onmove = function (/* el, old_roc_idx, new_roc_idx */) {
532 tp.helpers.editor.reselect();
533 tp.helpers.unsaved_changes.set();
534 };
535
536 /**
537 * [editor_onsort description]
538 *
539 * @param {[type]} el [description]
540 * @param {[type]} column [description]
541 * @param {[type]} order [description]
542 */
543 tp.callbacks.editor.onsort = function (/* el, column, order */) {
544 tp.editor.updateTable(); // Redraw table.
545 tp.helpers.unsaved_changes.set();
546 };
547
548 /**
549 * Copy the generated link or image HTML code from the helper textarea to the first selected table cell.
550 */
551 tp.helpers.editor.insert_from_helper_textarea = function () {
552 tp.editor.setValueFromCoords( tp.helpers.selection.columns[0], tp.helpers.selection.rows[0], this.value );
553 };
554
555 tp.callbacks.insert_link = {};
556
557 /**
558 * Open the wpLink dialog for inserting links.
559 *
560 * @param {HTMLElement|null} $active_textarea Active textarea of the table editor or null.
561 */
562 tp.callbacks.insert_link.open_dialog = function ( $active_textarea = null ) {
563 const $helper_textarea = $( '#textarea-insert-helper' );
564 $helper_textarea.value = tp.editor.options.data[ tp.helpers.selection.rows[0] ][ tp.helpers.selection.columns[0] ];
565 if ( $active_textarea ) {
566 $helper_textarea.selectionStart = $active_textarea.selectionStart;
567 $helper_textarea.selectionEnd = $active_textarea.selectionEnd;
568 } else {
569 $helper_textarea.selectionStart = $helper_textarea.value.length;
570 $helper_textarea.selectionEnd = $helper_textarea.value.length;
571 }
572 const cell_name = jexcel.getColumnNameFromId( [ tp.helpers.selection.columns[0], tp.helpers.selection.rows[0] ] );
573 $( '#link-modal-title' ).textContent = sprintf( __( 'Insert Link into cell %1$s', 'tablepress' ), cell_name );
574 wpLink.open( 'textarea-insert-helper' );
575 jexcel.current = null; // This is necessary to prevent problems with the focus when the "Insert Link" dialog is called from the context menu.
576 };
577
578 tp.callbacks.insert_image = {};
579
580 /**
581 * Open the WP Media library for inserting images.
582 *
583 * @param {HTMLElement|null} $active_textarea Active textarea of the table editor or null.
584 */
585 tp.callbacks.insert_image.open_dialog = function ( $active_textarea = null ) {
586 const $helper_textarea = $( '#textarea-insert-helper' );
587 $helper_textarea.value = tp.editor.options.data[ tp.helpers.selection.rows[0] ][ tp.helpers.selection.columns[0] ];
588 if ( $active_textarea ) {
589 $helper_textarea.selectionStart = $active_textarea.selectionStart;
590 $helper_textarea.selectionEnd = $active_textarea.selectionEnd;
591 } else {
592 $helper_textarea.selectionStart = $helper_textarea.value.length;
593 $helper_textarea.selectionEnd = $helper_textarea.value.length;
594 }
595 wp.media.editor.open( 'textarea-insert-helper', {
596 frame: 'post',
597 state: 'insert',
598 title: wp.media.view.l10n.addMedia,
599 multiple: true,
600 } );
601 const cell_name = jexcel.getColumnNameFromId( [ tp.helpers.selection.columns[0], tp.helpers.selection.rows[0] ] );
602 document.querySelector( '#media-frame-title h1' ).textContent = sprintf( __( 'Add media to cell %1$s', 'tablepress' ), cell_name );
603 jexcel.current = null; // This is necessary to prevent problems with the focus when the "Insert Link" dialog is called from the context menu.
604 };
605
606 tp.callbacks.advanced_editor = {};
607
608 tp.callbacks.advanced_editor.$textarea = $( '#advanced-editor-content' );
609
610 /**
611 * Open the wpdialog for the Advanced Editor.
612 *
613 * @param {HTMLElement|null} $active_textarea Active textarea of the table editor or null.
614 */
615 tp.callbacks.advanced_editor.open_dialog = function ( $active_textarea = null ) {
616 tp.callbacks.advanced_editor.$textarea.value = tp.editor.options.data[ tp.helpers.selection.rows[0] ][ tp.helpers.selection.columns[0] ];
617
618 const cell_name = jexcel.getColumnNameFromId( [ tp.helpers.selection.columns[0], tp.helpers.selection.rows[0] ] );
619 const title = sprintf( __( 'Advanced Editor for cell %1$s', 'tablepress' ), cell_name );
620 $( '#advanced-editor-label' ).textContent = title; // Screen reader label for the "Advanced Editor" textarea.
621 $( '#link-modal-title' ).textContent = sprintf( __( 'Insert Link into cell %1$s', 'tablepress' ), cell_name );
622
623 jQuery( '#advanced-editor' ).wpdialog( {
624 width: 600,
625 modal: true,
626 title,
627 resizable: false, // Height of textarea does not increase when resizing editor height.
628 closeOnEscape: true,
629 buttons: [
630 {
631 text: __( 'Cancel', 'tablepress' ),
632 class: 'button button-cancel',
633 click() {
634 jQuery( this ).wpdialog( 'close' );
635 },
636 },
637 {
638 text: __( 'OK', 'tablepress' ),
639 class: 'button button-primary button-ok',
640 click: tp.callbacks.advanced_editor.confirm_save,
641 },
642 ],
643 } );
644
645 jexcel.current = null; // This is necessary to prevent problems with the focus and cells being emptied when the Advanced Editor is called from the context menu.
646 if ( $active_textarea ) {
647 tp.callbacks.advanced_editor.$textarea.selectionStart = $active_textarea.selectionStart;
648 tp.callbacks.advanced_editor.$textarea.selectionEnd = $active_textarea.selectionEnd;
649 } else {
650 tp.callbacks.advanced_editor.$textarea.selectionStart = tp.callbacks.advanced_editor.$textarea.value.length;
651 tp.callbacks.advanced_editor.$textarea.selectionEnd = tp.callbacks.advanced_editor.$textarea.value.length;
652 }
653 tp.callbacks.advanced_editor.$textarea.focus();
654 };
655
656 /**
657 * Confirm and save changes of the Advanced Editor.
658 */
659 tp.callbacks.advanced_editor.confirm_save = function () {
660 const current_value = tp.editor.options.data[ tp.helpers.selection.rows[0] ][ tp.helpers.selection.columns[0] ];
661 // Only set the cell content if changes were made to not wrongly call tp.helpers.unsaved_changes.set().
662 if ( tp.callbacks.advanced_editor.$textarea.value !== current_value ) {
663 tp.editor.setValueFromCoords( tp.helpers.selection.columns[0], tp.helpers.selection.rows[0], tp.callbacks.advanced_editor.$textarea.value );
664 }
665 jQuery( this ).wpdialog( 'close' );
666 };
667
668 tp.callbacks.help_box = {};
669
670 /**
671 * Open the wpdialog for a help box.
672 *
673 * @param {Event} event [description]
674 */
675 tp.callbacks.help_box.open_dialog = function ( event ) {
676 const $helpbox = $( event.target.dataset.helpBox );
677 jQuery( $helpbox ).wpdialog( {
678 height: $helpbox.dataset.height,
679 width: $helpbox.dataset.width,
680 minWidth: 260,
681 modal: true,
682 closeOnEscape: true,
683 buttons: [
684 {
685 text: __( 'OK', 'tablepress' ),
686 class: 'button button-ok',
687 click() {
688 jQuery( this ).wpdialog( 'close' );
689 },
690 },
691 ],
692 open( /* event, ui */ ) {
693 jQuery( this ).next().find( '.button-ok' ).trigger( 'focus' );
694 },
695 } );
696 };
697
698 tp.callbacks.table_preview = {};
699
700 /**
701 * Handle showing the table preview.
702 *
703 * @param {Event} event [description]
704 */
705 tp.callbacks.table_preview.process = function ( event ) {
706 // Never follow the link of the Preview button, everything is handled with JS.
707 event.preventDefault();
708
709 let table_name = $( '#table-name' ).value;
710 if ( '' === table_name.trim() ) {
711 table_name = __( '(no name)', 'tablepress' );
712 }
713
714 // Initialize the Table Preview wpdialog.
715 tp.callbacks.table_preview.$dialog = jQuery( '#table-preview' ).wpdialog( {
716 autoOpen: false,
717 width: window.innerWidth - 80,
718 height: window.innerHeight - 80,
719 modal: true,
720 title: sprintf( __( 'Preview of table “%1$s” (ID %2$s)', 'tablepress' ), table_name, tp.table.id ),
721 closeOnEscape: true,
722 buttons: [
723 {
724 text: __( 'OK', 'tablepress' ),
725 class: 'button button-ok',
726 click() {
727 jQuery( this ).wpdialog( 'close' );
728 },
729 },
730 ],
731 } );
732
733 // For tables without unsaved changes, show an externally rendered table from a URL in an iframe in a wpdialog.
734 if ( ! tp.made_changes ) {
735 const $iframe = $( '#table-preview-iframe' );
736 $iframe.src = event.target.href;
737 $iframe.removeAttribute( 'srcdoc' );
738 tp.callbacks.table_preview.$dialog.wpdialog( 'open' );
739 return;
740 }
741
742 // For tables with unsaved changes, get the table preview HTML code for the iframe via AJAX.
743
744 // Collect information about hidden rows and columns.
745 tp.helpers.visibility.update();
746
747 // Prepare the data for the AJAX request.
748 const request_data = {
749 action: 'tablepress_preview_table',
750 _ajax_nonce: tp.nonces.preview_table,
751 tablepress: {
752 id: tp.table.id,
753 new_id: tp.table.new_id,
754 name: $( '#table-name' ).value,
755 description: $( '#table-description' ).value,
756 data: JSON.stringify( tp.editor.options.data ),
757 options: JSON.stringify( tp.table.options ),
758 visibility: JSON.stringify( tp.table.visibility ),
759 number: {
760 rows: tp.editor.options.data.length,
761 columns: tp.editor.options.columns.length,
762 },
763 },
764 };
765
766 // Add spinner, disable "Preview" buttons, and change cursor.
767 event.target.parentNode.insertAdjacentHTML( 'beforeend', `<span id="spinner-table-preview" class="spinner-table-preview spinner is-active" title="${ __( 'The Table Preview is being loaded …', 'tablepress' ) }"/>` );
768 $( '.button-preview' ).forEach( ( button ) => button.classList.add( 'disabled' ) );
769 document.body.classList.add( 'wait' );
770
771 // Load the table preview data from the server via an AJAX request.
772 fetch( ajaxurl, {
773 method: 'POST',
774 headers: {
775 'Content-Type': 'application/x-www-form-urlencoded',
776 Accept: 'application/json',
777 },
778 body: buildQueryString( request_data ),
779 } )
780 // Check for HTTP connection problems.
781 .then( ( response ) => {
782 if ( ! response.ok ) {
783 throw new Error( `There was a problem with the server, HTTP response code ${ response.status } (${ response.statusText }).` );
784 }
785 return response.json();
786 } )
787 // Check for problems with the transmitted data.
788 .then( ( data ) => {
789 if ( 'undefined' === typeof data || null === data || '-1' === data || 'undefined' === typeof data.success ) {
790 throw new Error( 'The JSON data returned from the server is unclear or incomplete.' );
791 }
792
793 if ( true !== data.success ) {
794 throw new Error( 'The preview could not be loaded.' );
795 }
796
797 tp.callbacks.table_preview.success( data );
798 } )
799 // Handle errors.
800 .catch( ( error ) => tp.callbacks.table_preview.error( error.message ) )
801 .finally( () => {
802 $( '#spinner-table-preview' ).remove();
803 $( '.button-preview' ).forEach( ( button ) => button.classList.remove( 'disabled' ) );
804 document.body.classList.remove( 'wait' );
805 } );
806 };
807
808 /**
809 * [success description]
810 *
811 * @param {[type]} data [description]
812 */
813 tp.callbacks.table_preview.success = function ( data ) {
814 const $iframe = $( '#table-preview-iframe' );
815 $iframe.src = '';
816 $iframe.srcdoc = `<!DOCTYPE html><html><head>${ data.head_html }</head><body>${ data.body_html }</body></html>`;
817
818 tp.callbacks.table_preview.$dialog.wpdialog( 'open' );
819 };
820
821 /**
822 * [error description]
823 *
824 * @param {[type]} message [description]
825 */
826 tp.callbacks.table_preview.error = function ( message ) {
827 message = __( 'Attention: Unfortunately, an error occurred.', 'tablepress' ) + ' ' + message;
828 const div_id = `show-preview-${ Date.now() }`;
829
830 $( '#spinner-table-preview' ).parentNode.insertAdjacentHTML( 'afterend', `<div id="${ div_id }" class="ajax-alert notice notice-error"><p>${ message }</p></div>` );
831
832 const $notice = $( `#${ div_id }` );
833 void $notice.offsetWidth; // Trick browser layout engine. Necessary to make CSS transition work.
834 $notice.style.opacity = 0;
835 $notice.addEventListener( 'transitionend', () => $notice.remove() );
836 };
837
838 tp.callbacks.save_changes = {};
839
840 /**
841 * Save Changes to the server.
842 *
843 * @param {Event} event [description]
844 */
845 tp.callbacks.save_changes.process = function ( event ) {
846 // Validate input fields.
847 if ( ! tp.helpers.options.validate_fields() ) {
848 return;
849 }
850
851 // Collect information about hidden rows and columns.
852 tp.helpers.visibility.update();
853
854 // Prepare the data for the AJAX request.
855 const request_data = {
856 action: 'tablepress_save_table',
857 _ajax_nonce: tp.nonces.edit_table,
858 tablepress: {
859 id: tp.table.id,
860 new_id: tp.table.new_id,
861 name: $( '#table-name' ).value,
862 description: $( '#table-description' ).value,
863 data: JSON.stringify( tp.editor.options.data ),
864 options: JSON.stringify( tp.table.options ),
865 visibility: JSON.stringify( tp.table.visibility ),
866 number: {
867 rows: tp.editor.options.data.length,
868 columns: tp.editor.options.columns.length,
869 },
870 },
871 };
872
873 // Add spinner, disable "Save Changes" buttons, and change cursor.
874 event.target.parentNode.insertAdjacentHTML( 'beforeend', `<span id="spinner-save-changes" class="spinner-save-changes spinner is-active" title="${ __( 'Changes are being saved …', 'tablepress' ) }"/>` );
875 $( '.button-save-changes' ).forEach( ( button ) => ( button.disabled = true ) );
876 document.body.classList.add( 'wait' );
877
878 // Save the table data to the server via an AJAX request.
879 fetch( ajaxurl, {
880 method: 'POST',
881 headers: {
882 'Content-Type': 'application/x-www-form-urlencoded',
883 Accept: 'application/json',
884 },
885 body: buildQueryString( request_data ),
886 } )
887 // Check for HTTP connection problems.
888 .then( ( response ) => {
889 if ( ! response.ok ) {
890 throw new Error( `There was a problem with the server, HTTP response code ${ response.status } (${ response.statusText }).` );
891 }
892 return response.json();
893 } )
894 // Check for problems with the transmitted data.
895 .then( ( data ) => {
896 if ( 'undefined' === typeof data || null === data || '-1' === data || 'undefined' === typeof data.success ) {
897 throw new Error( 'The JSON data returned from the server is unclear or incomplete.' );
898 }
899
900 if ( true !== data.success ) {
901 const error_introduction = __( 'These errors were encountered:', 'tablepress' );
902 const debug_html = data.error_details ? `</p><p>${ error_introduction }</p><pre>${ data.error_details }</pre><p>` : '';
903 throw new Error( `The table could not be saved to the database properly.${ debug_html }` );
904 }
905
906 tp.callbacks.save_changes.success( data );
907 } )
908 // Handle errors.
909 .catch( ( error ) => tp.callbacks.save_changes.error( error.message ) )
910 .finally( () => {
911 $( '#spinner-save-changes' ).remove();
912 $( '.button-save-changes' ).forEach( ( button ) => ( button.disabled = false ) );
913 document.body.classList.remove( 'wait' );
914 } );
915 };
916
917 /**
918 * [success description]
919 *
920 * @param {[type]} data [description]
921 */
922 tp.callbacks.save_changes.success = function ( data ) {
923 // Saving was successful, so the original ID has changed to the (maybe) new ID -> we need to adjust all occurrences.
924 if ( tp.table.id !== data.table_id && window?.history?.pushState ) {
925 // Update URL, but only if the table ID changed, to not get dummy entries in the browser history.
926 window.history.pushState( '', '', window.location.href.replace( /table_id=[0-9a-zA-Z-_]+/gi, `table_id=${ data.table_id }` ) );
927 }
928
929 // Update table ID in input field.
930 tp.table.id = data.table_id;
931 tp.table.new_id = data.table_id;
932 $( '#table-id' ).value = data.table_id;
933 const $shortcode_field = $( '#table-information-shortcode' );
934 if ( $shortcode_field ) {
935 $shortcode_field.value = `[${ tp.table.shortcode } id=${ data.table_id } /]`;
936 }
937
938 // Update the nonces.
939 tp.nonces.edit_table = data.new_edit_nonce;
940 tp.nonces.preview_table = data.new_preview_nonce;
941 tp.nonces.copy_table = data.new_copy_nonce;
942 tp.nonces.delete_table = data.new_delete_nonce;
943
944 // Update URLs in Preview, Copy, and Delete links/buttons.
945 [ 'preview', 'copy', 'delete' ].forEach( ( action ) => {
946 $( `.button-${ action }` ).forEach( ( button ) => {
947 button.href = button.href
948 .replace( /item=[a-zA-Z0-9_-]+/g, `item=${ data.table_id }` ) // Updates both the "item" and the "return_item" parameters.
949 .replace( /&_wpnonce=[a-z0-9]+/ig, `&_wpnonce=${ data[ `new_${ action }_nonce` ] }` );
950 } );
951 } );
952
953 // Update URL in Export links/buttons.
954 $( '.button-export' ).forEach( ( button ) => {
955 button.href = button.href
956 .replace( /table_id=[a-zA-Z0-9_-]+/g, `table_id=${ data.table_id }` );
957 } );
958
959 // Update last-modified date and user nickname.
960 $( '#last-modified' ).textContent = data.last_modified;
961 $( '#last-editor' ).textContent = data.last_editor;
962
963 tp.helpers.unsaved_changes.unset();
964
965 const action_messages = {};
966 action_messages.success_save = __( 'The table was saved successfully.', 'tablepress' );
967 action_messages.success_save_success_id_change = action_messages.success_save + ' ' + __( 'The table ID was changed.', 'tablepress' );
968 action_messages.success_save_error_id_change = action_messages.success_save + ' ' + __( 'The table ID could not be changed, probably because the new ID is already in use!', 'tablepress' );
969
970 if ( 'success_save_error_id_change' === data.message && data.error_details ) {
971 const error_introduction = __( 'These errors were encountered:', 'tablepress' );
972 action_messages.success_save_error_id_change += `</p><p>${ error_introduction }</p><pre>${ data.error_details }</pre><p>`;
973 }
974
975 const type = ( data.message.includes( 'error' ) ) ? 'error' : 'success';
976 tp.callbacks.save_changes.after_saving_notice( type, action_messages[ data.message ] );
977 };
978
979 /**
980 * [error description]
981 *
982 * @param {[type]} message [description]
983 */
984 tp.callbacks.save_changes.error = function ( message ) {
985 message = __( 'Attention: Unfortunately, an error occurred.', 'tablepress' ) + ' ' + message;
986 tp.callbacks.save_changes.after_saving_notice( 'error', message );
987 };
988
989 /**
990 * [after_saving_notice description]
991 *
992 * @param {[type]} type [description]
993 * @param {[type]} message [description]
994 */
995 tp.callbacks.save_changes.after_saving_notice = function ( type, message ) {
996 const div_id = `save-changes-${ Date.now() }`;
997
998 $( '#spinner-save-changes' ).parentNode.insertAdjacentHTML( 'afterend', `<div id="${ div_id }" class="ajax-alert notice notice-${ type }"><p>${ message }</p></div>` );
999
1000 const $notice = $( `#${ div_id }` );
1001 void $notice.offsetWidth; // Trick browser layout engine. Necessary to make CSS transition work.
1002 $notice.style.opacity = 0;
1003 $notice.addEventListener( 'transitionend', () => $notice.remove() );
1004 };
1005
1006 tp.callbacks.screen_options = {};
1007
1008 /**
1009 * Updates table editor layout with new screen option values.
1010 *
1011 * @param {Event} event `input` event of the screen options fields.
1012 */
1013 tp.callbacks.screen_options.update = function ( event ) {
1014 if ( ! event.target ) {
1015 return;
1016 }
1017
1018 if ( 'table_editor_line_clamp' === event.target.id ) {
1019 tp.editor.el.style.setProperty( '--table-editor-line-clamp', parseInt( event.target.value, 10 ) );
1020 tp.editor.updateCornerPosition();
1021 return;
1022 }
1023
1024 if ( 'table_editor_column_width' === event.target.id ) {
1025 tp.screen_options.table_editor_column_width = parseInt( event.target.value, 10 );
1026 tp.screen_options.table_editor_column_width = Math.max( tp.screen_options.table_editor_column_width, 30 ); // Ensure a minimum column width of 30 pixesl.
1027 tp.screen_options.table_editor_column_width = Math.min( tp.screen_options.table_editor_column_width, 9999 ); // Ensure a maximum column width of 9999 pixesl.
1028 tp.editor.colgroup.forEach( ( col ) => col.setAttribute( 'width', tp.screen_options.table_editor_column_width ) );
1029 tp.editor.updateCornerPosition();
1030 return;
1031 }
1032 };
1033
1034 /**
1035 * Designates a screen option field to have been changed, so that the value is sent to the server when it is blurred.
1036 *
1037 * @param {Event} event `change` event of the screen options fields.
1038 */
1039 tp.callbacks.screen_options.set_was_changed = function ( event ) {
1040 if ( ! event.target ) {
1041 return;
1042 }
1043
1044 event.target.was_changed = true;
1045 };
1046
1047 /**
1048 * Saves screen options to the server after they have been changed and the field is blurred.
1049 *
1050 * @param {Event} event `blur` event of the screen options fields.
1051 */
1052 tp.callbacks.screen_options.save = function ( event ) {
1053 if ( ! event.target ) {
1054 return;
1055 }
1056
1057 if ( ! event.target.was_changed ) {
1058 return;
1059 }
1060
1061 event.target.was_changed = false;
1062
1063 // Prepare the data for the AJAX request.
1064 const request_data = {
1065 action: 'tablepress_save_screen_options',
1066 _ajax_nonce: tp.nonces.screen_options,
1067 tablepress: {
1068 [ event.target.id ]: parseInt( event.target.value, 10 ),
1069 },
1070 };
1071
1072 // Add spinner and change cursor.
1073 event.target.parentNode.insertAdjacentHTML( 'beforeend', `<span id="spinner-save-changes" class="spinner-save-changes spinner is-active" title="${ __( 'Changes are being saved …', 'tablepress' ) }"/>` );
1074 document.body.classList.add( 'wait' );
1075
1076 // Save the table data to the server via an AJAX request.
1077 fetch( ajaxurl, {
1078 method: 'POST',
1079 headers: {
1080 'Content-Type': 'application/x-www-form-urlencoded',
1081 Accept: 'application/json',
1082 },
1083 body: buildQueryString( request_data ),
1084 } )
1085 .finally( () => {
1086 $( '#spinner-save-changes' ).remove();
1087 document.body.classList.remove( 'wait' );
1088 } );
1089 };
1090
1091 tp.callbacks.table_id = tp.callbacks.table_id || {};
1092
1093 /**
1094 * [sanitize_table_id description]
1095 */
1096 tp.callbacks.table_id.sanitize = function () {
1097 this.value = this.value.replace( /[^0-9a-zA-Z-_]/g, '' );
1098 };
1099
1100 /**
1101 * [change_table_id description]
1102 */
1103 tp.callbacks.table_id.change = function () {
1104 // The table IDs "" and "0" are not allowed, or in other words, the table ID has to fulfill /[A-Za-z1-9-_]|[A-Za-z0-9-_]{2,}/.
1105 if ( '' === this.value || '0' === this.value ) {
1106 window.alert( __( 'This table ID is invalid. Please enter a different table ID.', 'tablepress' ) );
1107 this.value = tp.table.new_id;
1108 this.focus();
1109 this.select();
1110 return;
1111 }
1112
1113 if ( ! window.confirm( __( 'Do you really want to change the Table ID? All blocks and Shortcodes for this table in your posts and pages will have to be adjusted!', 'tablepress' ) ) ) {
1114 this.value = tp.table.new_id;
1115 return;
1116 }
1117
1118 // Set the new table ID.
1119 tp.table.new_id = this.value;
1120 const $shortcode_field = $( '#table-information-shortcode' );
1121 if ( $shortcode_field ) {
1122 $shortcode_field.value = `[${ tp.table.shortcode } id=${ tp.table.new_id } /]`;
1123 $shortcode_field.focus();
1124 $shortcode_field.select();
1125 }
1126 tp.helpers.unsaved_changes.set();
1127 };
1128
1129 /**
1130 * Inserts or duplicates rows or columns before each currently selected row/column.
1131 *
1132 * @param {string} action The action to perform on the selected rows/columns ("insert" or "duplicate").
1133 * @param {string} type What to insert or duplicate ("rows" or "columns").
1134 * @param {string} position Where to insert or duplicate ("before" or "after"). Default "before".
1135 */
1136 tp.callbacks.insert_duplicate = function ( action, type, position = 'before' ) {
1137 const handling_rows = ( 'rows' === type );
1138 const insert_function = handling_rows ? tp.editor.insertRow : tp.editor.insertColumn;
1139 const getData_function = handling_rows ? tp.editor.getRowData : tp.editor.getColumnData;
1140 const duplicating = ( 'duplicate' === action );
1141 // Dynamically set the event handler, so that we have the action available in it.
1142 tp.editor.options[ handling_rows ? 'oninsertrow' : 'oninsertcolumn' ] = tp.callbacks.editor.oninsertroc.bind( null, type, action );
1143 tp.helpers.selection[ type ].forEach( function ( roc_idx, array_idx ) {
1144 const shifted_roc_idx = roc_idx + array_idx; // Not having to deal with shifted indices is possible by looping through the reversed array, but that's likely slower.
1145 const data = duplicating ? getData_function( shifted_roc_idx ) : 1;
1146 const position_bool = 'before' === position; // true means "before".
1147 insert_function( data, shifted_roc_idx, position_bool );
1148 } );
1149 tp.helpers.unsaved_changes.set();
1150
1151 // Select both inserted/duplicated rows/columns if more than one were selected.
1152 const num_selected_rocs = tp.helpers.selection[ type ].length;
1153 if ( num_selected_rocs > 1 ) {
1154 tp.editor.updateSelectionFromCoords(
1155 tp.helpers.selection.columns[0],
1156 tp.helpers.selection.rows[0],
1157 handling_rows ? tp.helpers.selection.columns[ tp.helpers.selection.columns.length - 1 ] : tp.helpers.selection.columns[ tp.helpers.selection.columns.length - 1 ] + num_selected_rocs,
1158 handling_rows ? tp.helpers.selection.rows[ tp.helpers.selection.rows.length - 1 ] + num_selected_rocs : tp.helpers.selection.rows[ tp.helpers.selection.rows.length - 1 ]
1159 );
1160 }
1161 };
1162
1163 /**
1164 * Removes currently selected rows or columns.
1165 *
1166 * @param {string} type What to remove ("rows" or "columns").
1167 */
1168 tp.callbacks.remove = function ( type ) {
1169 const handling_rows = 'rows' === type;
1170 const num_cors = handling_rows ? tp.editor.options.columns.length : tp.editor.options.data.length;
1171 const last_roc_idx = handling_rows ? tp.editor.options.data.length - 1 : tp.editor.options.columns.length - 1;
1172
1173 // Visibility meta information has to be deleted manually, as otherwise the Jspreadsheet meta information can get out of sync.
1174 if ( tp.editor.options.meta ) {
1175 tp.helpers.selection[ type ].forEach( function ( roc_idx ) {
1176 for ( let cor_idx = 0; cor_idx < num_cors; cor_idx++ ) {
1177 const cell_idx = handling_rows ? [ cor_idx, roc_idx ] : [ roc_idx, cor_idx ];
1178 delete tp.editor.options.meta[ jspreadsheet.getColumnNameFromId( cell_idx ) ];
1179 }
1180 } );
1181 }
1182
1183 const delete_function = handling_rows ? tp.editor.deleteRow : tp.editor.deleteColumn;
1184 delete_function( tp.helpers.selection[ type ][0], tp.helpers.selection[ type ].length );
1185 tp.helpers.unsaved_changes.set();
1186
1187 // Reselect last visible row/column, if last rows/columns were deleted.
1188 if ( last_roc_idx === tp.helpers.selection[ type ][ tp.helpers.selection[ type ].length - 1 ] ) {
1189 const col_idx = handling_rows ? tp.helpers.selection.columns[0] : tp.helpers.selection.columns[0] - 1;
1190 const row_idx = handling_rows ? tp.helpers.selection.rows[0] - 1 : tp.helpers.selection.rows[0];
1191 tp.editor.updateSelectionFromCoords( col_idx, row_idx, col_idx, row_idx );
1192 }
1193 };
1194
1195 /**
1196 * Appends rows or columns at the bottom or right end of the table.
1197 *
1198 * @param {string} type What to append ("rows" or "columns").
1199 * @param {number} num_rocs Number of rows or columns to append.
1200 */
1201 tp.callbacks.append = function ( type, num_rocs ) {
1202 const handling_rows = ( 'rows' === type );
1203 const insert_function = handling_rows ? tp.editor.insertRow : tp.editor.insertColumn;
1204 // Dynamically set the event handler, so that we have the action available in it.
1205 tp.editor.options[ handling_rows ? 'oninsertrow' : 'oninsertcolumn' ] = tp.callbacks.editor.oninsertroc.bind( null, type, 'append' );
1206 insert_function( num_rocs );
1207 tp.helpers.unsaved_changes.set();
1208 };
1209
1210 /**
1211 * Moves currently selected rows or columns.
1212 *
1213 * @param {string} direction Where to move the selected rows or columns (for rows: "up"/"down"/"top"/"bottom", for columns: "left"/right"/"first"/"last").
1214 * @param {string} type What to move ("rows" or "columns").
1215 */
1216 tp.callbacks.move = function ( direction, type ) {
1217 const handling_rows = ( 'rows' === type );
1218
1219 // Default case: up/left
1220 let rocs = tp.helpers.selection[ type ]; // When moving up or left, start with the first row/column of the selected range.
1221 let position_difference = -1; // New row/column number is one smaller than current row/column number.
1222 // Alternate case: down/right
1223 if ( 'down' === direction || 'right' === direction ) {
1224 rocs = rocs.slice().reverse(); // When moving down or right, reverse the order, to start with the last row/column of the selected range. slice() is needed here to create an array copy.
1225 position_difference = 1; // New row/column number is one higher than current row/column number.
1226 } else if ( 'top' === direction || 'first' === direction ) {
1227 position_difference = -rocs[0];
1228 } else if ( 'bottom' === direction || 'last' === direction ) {
1229 rocs = rocs.slice().reverse(); // When moving down or right, reverse the order, to start with the last row/column of the selected range. slice() is needed here to create an array copy.
1230 const min_max_roc = ( 'rows' === type ) ? tp.editor.options.data.length - 1 : tp.editor.options.columns.length - 1;
1231 position_difference = min_max_roc - rocs[0];
1232 }
1233
1234 // Bail early if there is nothing to do (e.g. when the selected range is already at the target edge).
1235 if ( 0 === position_difference ) {
1236 return;
1237 }
1238
1239 // Move the selected rows/columns individually.
1240 const move_function = handling_rows ? tp.editor.moveRow : tp.editor.moveColumn;
1241 rocs.forEach( ( roc_idx ) => move_function( roc_idx, roc_idx + position_difference ) );
1242 tp.helpers.unsaved_changes.set();
1243
1244 // Reselect moved selection.
1245 tp.editor.updateSelectionFromCoords(
1246 handling_rows ? tp.helpers.selection.columns[0] : tp.helpers.selection.columns[0] + position_difference,
1247 handling_rows ? tp.helpers.selection.rows[0] + position_difference : tp.helpers.selection.rows[0],
1248 handling_rows ? tp.helpers.selection.columns[ tp.helpers.selection.columns.length - 1 ] : tp.helpers.selection.columns[ tp.helpers.selection.columns.length - 1 ] + position_difference,
1249 handling_rows ? tp.helpers.selection.rows[ tp.helpers.selection.rows.length - 1 ] + position_difference : tp.helpers.selection.rows[ tp.helpers.selection.rows.length - 1 ]
1250 );
1251 };
1252
1253 /**
1254 * Sorts the table data by the first currently selected column.
1255 *
1256 * @param {string} direction Sort order/direction ("asc" for ascending, "desc" for descending).
1257 */
1258 tp.callbacks.sort = function ( direction ) {
1259 tp.editor.orderBy( tp.helpers.selection.columns[0], ( 'desc' === direction ) );
1260 };
1261
1262 /**
1263 * Hides or unhides selected rows or columns.
1264 *
1265 * @param {string} action The action to perform on the rows/columns ("hide" or "unhide").
1266 * @param {string} type What to hide or unhide ("rows" or "columns").
1267 */
1268 tp.callbacks.hide_unhide = function ( action, type ) {
1269 const handling_rows = ( 'rows' === type );
1270 const property = handling_rows ? 'row_hidden' : 'column_hidden';
1271 const num_cors = handling_rows ? tp.editor.options.columns.length : tp.editor.options.data.length;
1272 const cell_hidden = ( 'hide' === action );
1273 const meta = {};
1274 tp.helpers.selection[ type ].forEach( function ( roc_idx ) {
1275 for ( let cor_idx = 0; cor_idx < num_cors; cor_idx++ ) {
1276 const cell_idx = handling_rows ? [ cor_idx, roc_idx ] : [ roc_idx, cor_idx ];
1277 const cell_name = jspreadsheet.getColumnNameFromId( cell_idx );
1278 meta[ cell_name ] = {};
1279 meta[ cell_name ][ property ] = cell_hidden;
1280 }
1281 } );
1282 tp.editor.setMeta( meta );
1283 tp.helpers.unsaved_changes.set();
1284 tp.editor.updateTable(); // Redraw table.
1285 };
1286
1287 /**
1288 * Combines/merges the currently selected cells.
1289 */
1290 tp.callbacks.merge_cells = function () {
1291 const current_col_idx = tp.helpers.selection.columns[0];
1292 const current_row_idx = tp.helpers.selection.rows[0];
1293 const colspan = tp.helpers.selection.columns.length;
1294 const rowspan = tp.helpers.selection.rows.length;
1295 for ( let row_idx = 1; row_idx < rowspan; row_idx++ ) {
1296 tp.editor.setValueFromCoords( current_col_idx, current_row_idx + row_idx, '#rowspan#' );
1297 }
1298 for ( let col_idx = 1; col_idx < colspan; col_idx++ ) {
1299 tp.editor.setValueFromCoords( current_col_idx + col_idx, current_row_idx, '#colspan#' );
1300 }
1301 for ( let row_idx = 1; row_idx < rowspan; row_idx++ ) {
1302 for ( let col_idx = 1; col_idx < colspan; col_idx++ ) {
1303 tp.editor.setValueFromCoords( current_col_idx + col_idx, current_row_idx + row_idx, '#span#' );
1304 }
1305 }
1306 tp.helpers.unsaved_changes.set();
1307 };
1308
1309 /**
1310 * Registers keyboard events and triggers corresponding actions by emulating button clicks.
1311 *
1312 * @param {Event} event Keyboard event.
1313 */
1314 tp.callbacks.keyboard_shortcuts = function ( event ) {
1315 let action = '';
1316 let move_direction = '';
1317 let move_type = '';
1318
1319 if ( event.ctrlKey || event.metaKey ) {
1320 if ( 80 === event.keyCode ) {
1321 // Preview: Ctrl/Cmd + P.
1322 action = 'preview';
1323 } else if ( 83 === event.keyCode ) {
1324 // Save Changes: Ctrl/Cmd + S.
1325 action = 'save-changes';
1326 } else if ( 76 === event.keyCode ) {
1327 // Insert Link: Ctrl/Cmd + L.
1328 action = 'insert_link';
1329 } else if ( 73 === event.keyCode ) {
1330 // Insert Image: Ctrl/Cmd + I.
1331 action = 'insert_image';
1332 } else if ( 69 === event.keyCode ) {
1333 // Advanced Editor: Ctrl/Cmd + E.
1334 action = 'advanced_editor';
1335 } else if ( event.shiftKey && event.altKey && 38 === event.keyCode ) {
1336 // Move up: Ctrl/Cmd + Alt/Option + Shift + ↑.
1337 action = 'move';
1338 move_direction = 'top';
1339 move_type = 'rows';
1340 } else if ( event.shiftKey && event.altKey && 40 === event.keyCode ) {
1341 // Move down: Ctrl/Cmd + Alt/Option + Shift + ↓.
1342 action = 'move';
1343 move_direction = 'bottom';
1344 move_type = 'rows';
1345 } else if ( event.shiftKey && event.altKey && 37 === event.keyCode ) {
1346 // Move left: Ctrl/Cmd + Alt/Option + Shift + ←.
1347 action = 'move';
1348 move_direction = 'first';
1349 move_type = 'columns';
1350 } else if ( event.shiftKey && event.altKey && 39 === event.keyCode ) {
1351 // Move r: Ctrl/Cmd + Alt/Option + Shift + →.
1352 action = 'move';
1353 move_direction = 'last';
1354 move_type = 'columns';
1355 } else if ( event.shiftKey && 38 === event.keyCode ) {
1356 // Move up: Ctrl/Cmd + Shift + ↑.
1357 action = 'move';
1358 move_direction = 'up';
1359 move_type = 'rows';
1360 } else if ( event.shiftKey && 40 === event.keyCode ) {
1361 // Move down: Ctrl/Cmd + Shift + ↓.
1362 action = 'move';
1363 move_direction = 'down';
1364 move_type = 'rows';
1365 } else if ( event.shiftKey && 37 === event.keyCode ) {
1366 // Move left: Ctrl/Cmd + Shift + ←.
1367 action = 'move';
1368 move_direction = 'left';
1369 move_type = 'columns';
1370 } else if ( event.shiftKey && 39 === event.keyCode ) {
1371 // Move r: Ctrl/Cmd + Shift + →.
1372 action = 'move';
1373 move_direction = 'right';
1374 move_type = 'columns';
1375 }
1376 }
1377
1378 if ( 'save-changes' === action || 'preview' === action ) {
1379 // Blur the focussed element to make sure that all change events were triggered.
1380 document.activeElement.blur(); // eslint-disable-line @wordpress/no-global-active-element
1381
1382 /*
1383 * Emulate a click on the button corresponding to the action.
1384 * This way, things like notices will be shown, compared to directly calling the buttons' callbacks.
1385 */
1386 document.querySelector( `#tablepress_edit-buttons-2-submit .button-${ action }` ).click();
1387
1388 // Prevent the browser's native handling of the shortcut, i.e. showing the Save or Print dialogs.
1389 event.preventDefault();
1390 } else if ( 'insert_link' === action || 'insert_image' === action || 'advanced_editor' === action ) {
1391 // Only open the dialogs if an element in the table editor is focussed, to e.g. prevent multiple dialogs to be opened.
1392 if ( $( '#table-editor' ).contains( document.activeElement ) ) { // eslint-disable-line @wordpress/no-global-active-element
1393 const $active_textarea = ( 'TEXTAREA' === document.activeElement.tagName ) ? document.activeElement : null; // eslint-disable-line @wordpress/no-global-active-element
1394 // Open the "Insert Link", "Insert Image", or Advanced Editor" dialog.
1395 tp.callbacks[ action ].open_dialog( $active_textarea );
1396 }
1397
1398 // Prevent the browser's native handling of the shortcut.
1399 event.preventDefault();
1400 } else if ( 'move' === action ) {
1401 // Only move rows or columns if an element in the table editor is focussed, but not if the cell is being edited (to not prevent the browser's original shortcuts).
1402 if ( $( '#table-editor' ).contains( document.activeElement ) && 'TEXTAREA' !== document.activeElement.tagName ) { // eslint-disable-line @wordpress/no-global-active-element
1403 // Move the selected rows or columns.
1404 if ( tp.helpers.move_allowed( move_type, move_direction ) ) {
1405 tp.callbacks.move( move_direction, move_type );
1406 }
1407 }
1408
1409 // Stop the event propagation so that Jspreadsheet doesn't understand the arrow key as movement of the cursor, and prevent the browser's native handling of the shortcut.
1410 event.stopImmediatePropagation();
1411 }
1412 };
1413
1414 /*
1415 * Initialize Jspreadsheet.
1416 */
1417 tp.editor = jspreadsheet( $( '#table-editor' ), {
1418 data: tp.table.data,
1419 meta: tp.helpers.visibility.load(),
1420 wordWrap: true,
1421 rowDrag: true,
1422 rowResize: true,
1423 columnSorting: true,
1424 columnDrag: true,
1425 columnResize: true,
1426 defaultColWidth: tp.screen_options.table_editor_column_width,
1427 defaultColAlign: 'left',
1428 parseFormulas: false,
1429 allowExport: false,
1430 allowComments: false,
1431 allowManualInsertRow: false, // To prevent addition of new row when Enter is pressed in last row.
1432 allowManualInsertColumn: false, // To prevent addition of new column when Tab is pressed in last column.
1433 about: false,
1434 secureFormulas: false,
1435 detachForUpdates: true,
1436 onselection: tp.callbacks.editor.onselection,
1437 updateTable: tp.callbacks.editor.onupdatetable,
1438 contextMenu,
1439 sorting: tp.helpers.editor.sorting,
1440 // Keep the selection when certain events occur and the table loses focus.
1441 onmoverow: tp.callbacks.editor.onmove,
1442 onmovecolumn: tp.callbacks.editor.onmove,
1443 onblur: tp.helpers.editor.reselect,
1444 onload: tp.helpers.editor.reselect, // When the table is loaded, select the top-left cell A1.
1445 onchange: tp.helpers.unsaved_changes.set,
1446 onsort: tp.callbacks.editor.onsort,
1447 } );
1448
1449 tp.helpers.options.load();
1450
1451 /*
1452 * Register click callback for the "Preview" and "Save Changes" buttons.
1453 */
1454 $( '#tablepress-page' ).addEventListener( 'click', ( event ) => {
1455 if ( ! event.target ) {
1456 return;
1457 }
1458
1459 if ( event.target.matches( '.button-preview' ) ) {
1460 tp.callbacks.table_preview.process( event );
1461 return;
1462 }
1463
1464 if ( event.target.matches( '.button-save-changes' ) ) {
1465 tp.callbacks.save_changes.process( event );
1466 return;
1467 }
1468
1469 if ( event.target.matches( '.button-show-help-box' ) ) {
1470 tp.callbacks.help_box.open_dialog( event );
1471 return;
1472 }
1473 } );
1474
1475 /*
1476 * Register click callbacks for the table manipulation buttons.
1477 */
1478 $( '#tablepress-manipulation-controls' ).addEventListener( 'click', ( event ) => {
1479 if ( ! event.target ) {
1480 return;
1481 }
1482
1483 /*
1484 * Events that don't require a selection.
1485 */
1486
1487 if ( event.target.matches( '.button-append' ) ) {
1488 const type = event.target.dataset.type;
1489 const $input_field = $( `#${ type }-append-number` );
1490 const num_rocs = parseInt( $input_field.value, 10 );
1491 if ( isNaN( num_rocs ) || num_rocs < 1 || num_rocs > 99999 ) {
1492 const message = ( 'rows' === event.target.dataset.type ) ? __( 'The value for the number of rows is invalid!', 'tablepress' ) : __( 'The value for the number of columns is invalid!', 'tablepress' );
1493 window.alert( message );
1494 $input_field.focus();
1495 $input_field.select();
1496 return;
1497 }
1498
1499 tp.callbacks.append( type, num_rocs );
1500 return;
1501 }
1502
1503 /*
1504 * Events that do require a selection.
1505 */
1506
1507 if ( 'button-insert-link' === event.target.id ) {
1508 tp.callbacks.insert_link.open_dialog();
1509 return;
1510 }
1511
1512 if ( 'button-insert-image' === event.target.id ) {
1513 tp.callbacks.insert_image.open_dialog();
1514 return;
1515 }
1516
1517 if ( 'button-advanced-editor' === event.target.id ) {
1518 tp.callbacks.advanced_editor.open_dialog();
1519 return;
1520 }
1521
1522 if ( event.target.matches( '.button-insert-duplicate' ) ) {
1523 tp.callbacks.insert_duplicate( event.target.dataset.action, event.target.dataset.type );
1524 return;
1525 }
1526
1527 if ( event.target.matches( '.button-move' ) ) {
1528 if ( ! tp.helpers.move_allowed( event.target.dataset.type, event.target.dataset.direction ) ) {
1529 window.alert( __( 'You can not do this move, because you reached the border of the table.', 'tablepress' ) );
1530 return;
1531 }
1532 tp.callbacks.move( event.target.dataset.direction, event.target.dataset.type );
1533 return;
1534 }
1535
1536 if ( event.target.matches( '.button-remove' ) ) {
1537 const handling_rows = ( 'rows' === event.target.dataset.type );
1538 const num_rocs = handling_rows ? tp.editor.options.data.length : tp.editor.options.columns.length;
1539
1540 if ( num_rocs === tp.helpers.selection[ event.target.dataset.type ].length ) {
1541 const message = handling_rows ? __( 'You can not delete all table rows!', 'tablepress' ) : __( 'You can not delete all table columns!', 'tablepress' );
1542 window.alert( message );
1543 return;
1544 }
1545
1546 tp.callbacks.remove( event.target.dataset.type );
1547 return;
1548 }
1549
1550 if ( event.target.matches( '.button-merge-unmerge' ) ) {
1551 if ( tp.helpers.cell_merge_allowed( 'alert' ) ) {
1552 tp.callbacks.merge_cells();
1553 }
1554 return;
1555 }
1556
1557 if ( event.target.matches( '.button-hide-unhide' ) ) {
1558 tp.callbacks.hide_unhide( event.target.dataset.action, event.target.dataset.type );
1559 return;
1560 }
1561 } );
1562
1563 // Register callbacks for the table ID text field.
1564 const $table_id_field = $( '#table-id' );
1565 $table_id_field.addEventListener( 'input', tp.callbacks.table_id.sanitize );
1566 $table_id_field.addEventListener( 'change', tp.callbacks.table_id.change );
1567
1568 // Select Shortcode input field content when it's focussed.
1569 const $table_information_shortcode = $( '#table-information-shortcode' );
1570 if ( $table_information_shortcode ) {
1571 $table_information_shortcode.addEventListener( 'focus', function() {
1572 this.select();
1573 } );
1574 }
1575
1576 // Register callback for inserting a link into a cell after it has been constructed in the wpLink dialog.
1577 jQuery( '#textarea-insert-helper' ).on( 'change', tp.helpers.editor.insert_from_helper_textarea ); // This must use jQuery, as wpLink triggers jQuery events, which can not be observed by native JS listeners.
1578
1579 // Register change callbacks for the table name, description, and options.
1580 [ '#table-name', '#table-description' ].forEach( ( field_id ) => $( field_id ).addEventListener( 'change', tp.helpers.unsaved_changes.set ) );
1581 const options_meta_boxes = apply_filters( 'tablepress.optionsMetaBoxes', [ '#tablepress_edit-table-options', '#tablepress_edit-datatables-features' ] );
1582 options_meta_boxes.forEach( ( meta_box_id ) => $( meta_box_id ).addEventListener( 'change', tp.helpers.options.change ) );
1583
1584 // Move all "Help" buttons inside the postbox header.
1585 document.querySelectorAll( '#tablepress-body .button-module-help' ).forEach( ( $button ) => ( $button.closest( '.postbox' ).querySelector( '.handle-actions' ).prepend( $button ) ) );
1586
1587 // Register callbacks for the screen options.
1588 const $tablepress_screen_options = $( '#tablepress-screen-options' );
1589 $tablepress_screen_options.addEventListener( 'input', tp.callbacks.screen_options.update );
1590 $tablepress_screen_options.addEventListener( 'change', tp.callbacks.screen_options.set_was_changed );
1591 $tablepress_screen_options.addEventListener( 'focusout', tp.callbacks.screen_options.save ); // Use the `focusout` event instead of `blur` as that does not bubble.
1592
1593 // Register keyboard shortcut handler.
1594 window.addEventListener( 'keydown', tp.callbacks.keyboard_shortcuts, true );
1595
1596 // Add keyboard shortcuts as title attributes to "Preview" and "Save Changes" buttons, with correct modifier key for Mac/non-Mac.
1597 const modifier_key = ( window?.navigator?.platform?.includes( 'Mac' ) ) ?
1598 _x( '', 'keyboard shortcut modifier key on a Mac keyboard', 'tablepress' ) :
1599 _x( 'Ctrl+', 'keyboard shortcut modifier key on a non-Mac keyboard', 'tablepress' );
1600 document.querySelectorAll( '.button[data-shortcut]' ).forEach( ( $button ) => {
1601 const shortcut = sprintf( $button.dataset.shortcut, modifier_key ); // eslint-disable-line @wordpress/valid-sprintf
1602 $button.title = sprintf( __( 'Keyboard Shortcut: %s', 'tablepress' ), shortcut );
1603 } );
1604
1605 // This code requires jQuery, and it must run when the DOM is ready. Therefore, move it outside of the main function.
1606 jQuery( function () {
1607 // Fix issue with wpLink input fields not being usable, when called through the "Advanced Editor". They are immediately losing focus without this.
1608 jQuery( '#wp-link' ).on( 'focus', 'input', function ( event ) {
1609 event.stopPropagation();
1610 } );
1611
1612 // Fix issue with Media Library input fields in the sidebar not being usable, when called through the "Advanced Editor". They are immediately losing focus without this.
1613 jQuery( 'body' ).on( 'focus', '.media-modal .media-frame-content input, .media-modal .media-frame-content textarea', function ( event ) {
1614 event.stopPropagation();
1615 } );
1616 } );
1617