PluginProbe
TablePress – Tables in WordPress made easy / 2.1.7
TablePress – Tables in WordPress made easy v2.1.7
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.1.7, at admin/js/edit.js

1,607 lines 61.4 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-show-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-show-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
942 // Update URLs in Preview links.
943 $( '.button-show-preview' ).forEach( ( button ) => {
944 button.href = button.href
945 .replace( /item=[a-zA-Z0-9_-]+/g, `item=${ data.table_id }` )
946 .replace( /&_wpnonce=[a-z0-9]+/ig, `&_wpnonce=${ data.new_preview_nonce }` );
947 } );
948
949 // Update last-modified date and user nickname.
950 $( '#last-modified' ).textContent = data.last_modified;
951 $( '#last-editor' ).textContent = data.last_editor;
952
953 tp.helpers.unsaved_changes.unset();
954
955 const action_messages = {};
956 action_messages.success_save = __( 'The table was saved successfully.', 'tablepress' );
957 action_messages.success_save_success_id_change = action_messages.success_save + ' ' + __( 'The table ID was changed.', 'tablepress' );
958 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' );
959
960 if ( 'success_save_error_id_change' === data.message && data.error_details ) {
961 const error_introduction = __( 'These errors were encountered:', 'tablepress' );
962 action_messages.success_save_error_id_change += `</p><p>${ error_introduction }</p><pre>${ data.error_details }</pre><p>`;
963 }
964
965 const type = ( data.message.includes( 'error' ) ) ? 'error' : 'success';
966 tp.callbacks.save_changes.after_saving_notice( type, action_messages[ data.message ] );
967 };
968
969 /**
970 * [error description]
971 *
972 * @param {[type]} message [description]
973 */
974 tp.callbacks.save_changes.error = function ( message ) {
975 message = __( 'Attention: Unfortunately, an error occurred.', 'tablepress' ) + ' ' + message;
976 tp.callbacks.save_changes.after_saving_notice( 'error', message );
977 };
978
979 /**
980 * [after_saving_notice description]
981 *
982 * @param {[type]} type [description]
983 * @param {[type]} message [description]
984 */
985 tp.callbacks.save_changes.after_saving_notice = function ( type, message ) {
986 const div_id = `save-changes-${ Date.now() }`;
987
988 $( '#spinner-save-changes' ).parentNode.insertAdjacentHTML( 'afterend', `<div id="${ div_id }" class="ajax-alert notice notice-${ type }"><p>${ message }</p></div>` );
989
990 const $notice = $( `#${ div_id }` );
991 void $notice.offsetWidth; // Trick browser layout engine. Necessary to make CSS transition work.
992 $notice.style.opacity = 0;
993 $notice.addEventListener( 'transitionend', () => $notice.remove() );
994 };
995
996 tp.callbacks.screen_options = {};
997
998 /**
999 * Updates table editor layout with new screen option values.
1000 *
1001 * @param {Event} event `input` event of the screen options fields.
1002 */
1003 tp.callbacks.screen_options.update = function ( event ) {
1004 if ( ! event.target ) {
1005 return;
1006 }
1007
1008 if ( 'table_editor_line_clamp' === event.target.id ) {
1009 tp.editor.el.style.setProperty( '--table-editor-line-clamp', parseInt( event.target.value, 10 ) );
1010 tp.editor.updateCornerPosition();
1011 return;
1012 }
1013
1014 if ( 'table_editor_column_width' === event.target.id ) {
1015 tp.screen_options.table_editor_column_width = parseInt( event.target.value, 10 );
1016 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.
1017 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.
1018 tp.editor.colgroup.forEach( ( col ) => col.setAttribute( 'width', tp.screen_options.table_editor_column_width ) );
1019 tp.editor.updateCornerPosition();
1020 return;
1021 }
1022 };
1023
1024 /**
1025 * Designates a screen option field to have been changed, so that the value is sent to the server when it is blurred.
1026 *
1027 * @param {Event} event `change` event of the screen options fields.
1028 */
1029 tp.callbacks.screen_options.set_was_changed = function ( event ) {
1030 if ( ! event.target ) {
1031 return;
1032 }
1033
1034 event.target.was_changed = true;
1035 };
1036
1037 /**
1038 * Saves screen options to the server after they have been changed and the field is blurred.
1039 *
1040 * @param {Event} event `blur` event of the screen options fields.
1041 */
1042 tp.callbacks.screen_options.save = function ( event ) {
1043 if ( ! event.target ) {
1044 return;
1045 }
1046
1047 if ( ! event.target.was_changed ) {
1048 return;
1049 }
1050
1051 event.target.was_changed = false;
1052
1053 // Prepare the data for the AJAX request.
1054 const request_data = {
1055 action: 'tablepress_save_screen_options',
1056 _ajax_nonce: tp.nonces.screen_options,
1057 tablepress: {
1058 [ event.target.id ]: parseInt( event.target.value, 10 ),
1059 },
1060 };
1061
1062 // Add spinner and change cursor.
1063 event.target.parentNode.insertAdjacentHTML( 'beforeend', `<span id="spinner-save-changes" class="spinner-save-changes spinner is-active" title="${ __( 'Changes are being saved …', 'tablepress' ) }"/>` );
1064 document.body.classList.add( 'wait' );
1065
1066 // Save the table data to the server via an AJAX request.
1067 fetch( ajaxurl, {
1068 method: 'POST',
1069 headers: {
1070 'Content-Type': 'application/x-www-form-urlencoded',
1071 Accept: 'application/json',
1072 },
1073 body: buildQueryString( request_data ),
1074 } )
1075 .finally( () => {
1076 $( '#spinner-save-changes' ).remove();
1077 document.body.classList.remove( 'wait' );
1078 } );
1079 };
1080
1081 tp.callbacks.table_id = tp.callbacks.table_id || {};
1082
1083 /**
1084 * [sanitize_table_id description]
1085 */
1086 tp.callbacks.table_id.sanitize = function () {
1087 this.value = this.value.replace( /[^0-9a-zA-Z-_]/g, '' );
1088 };
1089
1090 /**
1091 * [change_table_id description]
1092 */
1093 tp.callbacks.table_id.change = function () {
1094 // 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,}/.
1095 if ( '' === this.value || '0' === this.value ) {
1096 window.alert( __( 'This table ID is invalid. Please enter a different table ID.', 'tablepress' ) );
1097 this.value = tp.table.new_id;
1098 this.focus();
1099 this.select();
1100 return;
1101 }
1102
1103 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' ) ) ) {
1104 this.value = tp.table.new_id;
1105 return;
1106 }
1107
1108 // Set the new table ID.
1109 tp.table.new_id = this.value;
1110 const $shortcode_field = $( '#table-information-shortcode' );
1111 if ( $shortcode_field ) {
1112 $shortcode_field.value = `[${ tp.table.shortcode } id=${ tp.table.new_id } /]`;
1113 $shortcode_field.focus();
1114 $shortcode_field.select();
1115 }
1116 tp.helpers.unsaved_changes.set();
1117 };
1118
1119 /**
1120 * Inserts or duplicates rows or columns before each currently selected row/column.
1121 *
1122 * @param {string} action The action to perform on the selected rows/columns ("insert" or "duplicate").
1123 * @param {string} type What to insert or duplicate ("rows" or "columns").
1124 * @param {string} position Where to insert or duplicate ("before" or "after"). Default "before".
1125 */
1126 tp.callbacks.insert_duplicate = function ( action, type, position = 'before' ) {
1127 const handling_rows = ( 'rows' === type );
1128 const insert_function = handling_rows ? tp.editor.insertRow : tp.editor.insertColumn;
1129 const getData_function = handling_rows ? tp.editor.getRowData : tp.editor.getColumnData;
1130 const duplicating = ( 'duplicate' === action );
1131 // Dynamically set the event handler, so that we have the action available in it.
1132 tp.editor.options[ handling_rows ? 'oninsertrow' : 'oninsertcolumn' ] = tp.callbacks.editor.oninsertroc.bind( null, type, action );
1133 tp.helpers.selection[ type ].forEach( function ( roc_idx, array_idx ) {
1134 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.
1135 const data = duplicating ? getData_function( shifted_roc_idx ) : 1;
1136 const position_bool = 'before' === position; // true means "before".
1137 insert_function( data, shifted_roc_idx, position_bool );
1138 } );
1139 tp.helpers.unsaved_changes.set();
1140
1141 // Select both inserted/duplicated rows/columns if more than one were selected.
1142 const num_selected_rocs = tp.helpers.selection[ type ].length;
1143 if ( num_selected_rocs > 1 ) {
1144 tp.editor.updateSelectionFromCoords(
1145 tp.helpers.selection.columns[0],
1146 tp.helpers.selection.rows[0],
1147 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,
1148 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 ]
1149 );
1150 }
1151 };
1152
1153 /**
1154 * Removes currently selected rows or columns.
1155 *
1156 * @param {string} type What to remove ("rows" or "columns").
1157 */
1158 tp.callbacks.remove = function ( type ) {
1159 const handling_rows = 'rows' === type;
1160 const num_cors = handling_rows ? tp.editor.options.columns.length : tp.editor.options.data.length;
1161 const last_roc_idx = handling_rows ? tp.editor.options.data.length - 1 : tp.editor.options.columns.length - 1;
1162
1163 // Visibility meta information has to be deleted manually, as otherwise the Jspreadsheet meta information can get out of sync.
1164 if ( tp.editor.options.meta ) {
1165 tp.helpers.selection[ type ].forEach( function ( roc_idx ) {
1166 for ( let cor_idx = 0; cor_idx < num_cors; cor_idx++ ) {
1167 const cell_idx = handling_rows ? [ cor_idx, roc_idx ] : [ roc_idx, cor_idx ];
1168 delete tp.editor.options.meta[ jspreadsheet.getColumnNameFromId( cell_idx ) ];
1169 }
1170 } );
1171 }
1172
1173 const delete_function = handling_rows ? tp.editor.deleteRow : tp.editor.deleteColumn;
1174 delete_function( tp.helpers.selection[ type ][0], tp.helpers.selection[ type ].length );
1175 tp.helpers.unsaved_changes.set();
1176
1177 // Reselect last visible row/column, if last rows/columns were deleted.
1178 if ( last_roc_idx === tp.helpers.selection[ type ][ tp.helpers.selection[ type ].length - 1 ] ) {
1179 const col_idx = handling_rows ? tp.helpers.selection.columns[0] : tp.helpers.selection.columns[0] - 1;
1180 const row_idx = handling_rows ? tp.helpers.selection.rows[0] - 1 : tp.helpers.selection.rows[0];
1181 tp.editor.updateSelectionFromCoords( col_idx, row_idx, col_idx, row_idx );
1182 }
1183 };
1184
1185 /**
1186 * Appends rows or columns at the bottom or right end of the table.
1187 *
1188 * @param {string} type What to append ("rows" or "columns").
1189 * @param {number} num_rocs Number of rows or columns to append.
1190 */
1191 tp.callbacks.append = function ( type, num_rocs ) {
1192 const handling_rows = ( 'rows' === type );
1193 const insert_function = handling_rows ? tp.editor.insertRow : tp.editor.insertColumn;
1194 // Dynamically set the event handler, so that we have the action available in it.
1195 tp.editor.options[ handling_rows ? 'oninsertrow' : 'oninsertcolumn' ] = tp.callbacks.editor.oninsertroc.bind( null, type, 'append' );
1196 insert_function( num_rocs );
1197 tp.helpers.unsaved_changes.set();
1198 };
1199
1200 /**
1201 * Moves currently selected rows or columns.
1202 *
1203 * @param {string} direction Where to move the selected rows or columns (for rows: "up"/"down"/"top"/"bottom", for columns: "left"/right"/"first"/"last").
1204 * @param {string} type What to move ("rows" or "columns").
1205 */
1206 tp.callbacks.move = function ( direction, type ) {
1207 const handling_rows = ( 'rows' === type );
1208
1209 // Default case: up/left
1210 let rocs = tp.helpers.selection[ type ]; // When moving up or left, start with the first row/column of the selected range.
1211 let position_difference = -1; // New row/column number is one smaller than current row/column number.
1212 // Alternate case: down/right
1213 if ( 'down' === direction || 'right' === direction ) {
1214 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.
1215 position_difference = 1; // New row/column number is one higher than current row/column number.
1216 } else if ( 'top' === direction || 'first' === direction ) {
1217 position_difference = -rocs[0];
1218 } else if ( 'bottom' === direction || 'last' === direction ) {
1219 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.
1220 const min_max_roc = ( 'rows' === type ) ? tp.editor.options.data.length - 1 : tp.editor.options.columns.length - 1;
1221 position_difference = min_max_roc - rocs[0];
1222 }
1223
1224 // Bail early if there is nothing to do (e.g. when the selected range is already at the target edge).
1225 if ( 0 === position_difference ) {
1226 return;
1227 }
1228
1229 // Move the selected rows/columns individually.
1230 const move_function = handling_rows ? tp.editor.moveRow : tp.editor.moveColumn;
1231 rocs.forEach( ( roc_idx ) => move_function( roc_idx, roc_idx + position_difference ) );
1232 tp.helpers.unsaved_changes.set();
1233
1234 // Reselect moved selection.
1235 tp.editor.updateSelectionFromCoords(
1236 handling_rows ? tp.helpers.selection.columns[0] : tp.helpers.selection.columns[0] + position_difference,
1237 handling_rows ? tp.helpers.selection.rows[0] + position_difference : tp.helpers.selection.rows[0],
1238 handling_rows ? tp.helpers.selection.columns[ tp.helpers.selection.columns.length - 1 ] : tp.helpers.selection.columns[ tp.helpers.selection.columns.length - 1 ] + position_difference,
1239 handling_rows ? tp.helpers.selection.rows[ tp.helpers.selection.rows.length - 1 ] + position_difference : tp.helpers.selection.rows[ tp.helpers.selection.rows.length - 1 ]
1240 );
1241 };
1242
1243 /**
1244 * Sorts the table data by the first currently selected column.
1245 *
1246 * @param {string} direction Sort order/direction ("asc" for ascending, "desc" for descending).
1247 */
1248 tp.callbacks.sort = function ( direction ) {
1249 tp.editor.orderBy( tp.helpers.selection.columns[0], ( 'desc' === direction ) );
1250 };
1251
1252 /**
1253 * Hides or unhides selected rows or columns.
1254 *
1255 * @param {string} action The action to perform on the rows/columns ("hide" or "unhide").
1256 * @param {string} type What to hide or unhide ("rows" or "columns").
1257 */
1258 tp.callbacks.hide_unhide = function ( action, type ) {
1259 const handling_rows = ( 'rows' === type );
1260 const property = handling_rows ? 'row_hidden' : 'column_hidden';
1261 const num_cors = handling_rows ? tp.editor.options.columns.length : tp.editor.options.data.length;
1262 const cell_hidden = ( 'hide' === action );
1263 const meta = {};
1264 tp.helpers.selection[ type ].forEach( function ( roc_idx ) {
1265 for ( let cor_idx = 0; cor_idx < num_cors; cor_idx++ ) {
1266 const cell_idx = handling_rows ? [ cor_idx, roc_idx ] : [ roc_idx, cor_idx ];
1267 const cell_name = jspreadsheet.getColumnNameFromId( cell_idx );
1268 meta[ cell_name ] = {};
1269 meta[ cell_name ][ property ] = cell_hidden;
1270 }
1271 } );
1272 tp.editor.setMeta( meta );
1273 tp.helpers.unsaved_changes.set();
1274 tp.editor.updateTable(); // Redraw table.
1275 };
1276
1277 /**
1278 * Combines/merges the currently selected cells.
1279 */
1280 tp.callbacks.merge_cells = function () {
1281 const current_col_idx = tp.helpers.selection.columns[0];
1282 const current_row_idx = tp.helpers.selection.rows[0];
1283 const colspan = tp.helpers.selection.columns.length;
1284 const rowspan = tp.helpers.selection.rows.length;
1285 for ( let row_idx = 1; row_idx < rowspan; row_idx++ ) {
1286 tp.editor.setValueFromCoords( current_col_idx, current_row_idx + row_idx, '#rowspan#' );
1287 }
1288 for ( let col_idx = 1; col_idx < colspan; col_idx++ ) {
1289 tp.editor.setValueFromCoords( current_col_idx + col_idx, current_row_idx, '#colspan#' );
1290 }
1291 for ( let row_idx = 1; row_idx < rowspan; row_idx++ ) {
1292 for ( let col_idx = 1; col_idx < colspan; col_idx++ ) {
1293 tp.editor.setValueFromCoords( current_col_idx + col_idx, current_row_idx + row_idx, '#span#' );
1294 }
1295 }
1296 tp.helpers.unsaved_changes.set();
1297 };
1298
1299 /**
1300 * Registers keyboard events and triggers corresponding actions by emulating button clicks.
1301 *
1302 * @param {Event} event Keyboard event.
1303 */
1304 tp.callbacks.keyboard_shortcuts = function ( event ) {
1305 let action = '';
1306 let move_direction = '';
1307 let move_type = '';
1308
1309 if ( event.ctrlKey || event.metaKey ) {
1310 if ( 80 === event.keyCode ) {
1311 // Preview: Ctrl/Cmd + P.
1312 action = 'show-preview';
1313 } else if ( 83 === event.keyCode ) {
1314 // Save Changes: Ctrl/Cmd + S.
1315 action = 'save-changes';
1316 } else if ( 76 === event.keyCode ) {
1317 // Insert Link: Ctrl/Cmd + L.
1318 action = 'insert_link';
1319 } else if ( 73 === event.keyCode ) {
1320 // Insert Image: Ctrl/Cmd + I.
1321 action = 'insert_image';
1322 } else if ( 69 === event.keyCode ) {
1323 // Advanced Editor: Ctrl/Cmd + E.
1324 action = 'advanced_editor';
1325 } else if ( event.shiftKey && event.altKey && 38 === event.keyCode ) {
1326 // Move up: Ctrl/Cmd + Alt/Option + Shift + ↑.
1327 action = 'move';
1328 move_direction = 'top';
1329 move_type = 'rows';
1330 } else if ( event.shiftKey && event.altKey && 40 === event.keyCode ) {
1331 // Move down: Ctrl/Cmd + Alt/Option + Shift + ↓.
1332 action = 'move';
1333 move_direction = 'bottom';
1334 move_type = 'rows';
1335 } else if ( event.shiftKey && event.altKey && 37 === event.keyCode ) {
1336 // Move left: Ctrl/Cmd + Alt/Option + Shift + ←.
1337 action = 'move';
1338 move_direction = 'first';
1339 move_type = 'columns';
1340 } else if ( event.shiftKey && event.altKey && 39 === event.keyCode ) {
1341 // Move r: Ctrl/Cmd + Alt/Option + Shift + →.
1342 action = 'move';
1343 move_direction = 'last';
1344 move_type = 'columns';
1345 } else if ( event.shiftKey && 38 === event.keyCode ) {
1346 // Move up: Ctrl/Cmd + Shift + ↑.
1347 action = 'move';
1348 move_direction = 'up';
1349 move_type = 'rows';
1350 } else if ( event.shiftKey && 40 === event.keyCode ) {
1351 // Move down: Ctrl/Cmd + Shift + ↓.
1352 action = 'move';
1353 move_direction = 'down';
1354 move_type = 'rows';
1355 } else if ( event.shiftKey && 37 === event.keyCode ) {
1356 // Move left: Ctrl/Cmd + Shift + ←.
1357 action = 'move';
1358 move_direction = 'left';
1359 move_type = 'columns';
1360 } else if ( event.shiftKey && 39 === event.keyCode ) {
1361 // Move r: Ctrl/Cmd + Shift + →.
1362 action = 'move';
1363 move_direction = 'right';
1364 move_type = 'columns';
1365 }
1366 }
1367
1368 if ( 'save-changes' === action || 'show-preview' === action ) {
1369 // Blur the focussed element to make sure that all change events were triggered.
1370 document.activeElement.blur(); // eslint-disable-line @wordpress/no-global-active-element
1371
1372 /*
1373 * Emulate a click on the button corresponding to the action.
1374 * This way, things like notices will be shown, compared to directly calling the buttons' callbacks.
1375 */
1376 document.querySelector( `#tablepress_edit-buttons-2-submit .button-${ action }` ).click();
1377
1378 // Prevent the browser's native handling of the shortcut, i.e. showing the Save or Print dialogs.
1379 event.preventDefault();
1380 } else if ( 'insert_link' === action || 'insert_image' === action || 'advanced_editor' === action ) {
1381 // Only open the dialogs if an element in the table editor is focussed, to e.g. prevent multiple dialogs to be opened.
1382 if ( $( '#table-editor' ).contains( document.activeElement ) ) { // eslint-disable-line @wordpress/no-global-active-element
1383 const $active_textarea = ( 'TEXTAREA' === document.activeElement.tagName ) ? document.activeElement : null; // eslint-disable-line @wordpress/no-global-active-element
1384 // Open the "Insert Link", "Insert Image", or Advanced Editor" dialog.
1385 tp.callbacks[ action ].open_dialog( $active_textarea );
1386 }
1387
1388 // Prevent the browser's native handling of the shortcut.
1389 event.preventDefault();
1390 } else if ( 'move' === action ) {
1391 // 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).
1392 if ( $( '#table-editor' ).contains( document.activeElement ) && 'TEXTAREA' !== document.activeElement.tagName ) { // eslint-disable-line @wordpress/no-global-active-element
1393 // Move the selected rows or columns.
1394 if ( tp.helpers.move_allowed( move_type, move_direction ) ) {
1395 tp.callbacks.move( move_direction, move_type );
1396 }
1397 }
1398
1399 // 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.
1400 event.stopImmediatePropagation();
1401 }
1402 };
1403
1404 /*
1405 * Initialize Jspreadsheet.
1406 */
1407 tp.editor = jspreadsheet( $( '#table-editor' ), {
1408 data: tp.table.data,
1409 meta: tp.helpers.visibility.load(),
1410 wordWrap: true,
1411 rowDrag: true,
1412 rowResize: true,
1413 columnSorting: true,
1414 columnDrag: true,
1415 columnResize: true,
1416 defaultColWidth: tp.screen_options.table_editor_column_width,
1417 defaultColAlign: 'left',
1418 parseFormulas: false,
1419 allowExport: false,
1420 allowComments: false,
1421 allowManualInsertRow: false, // To prevent addition of new row when Enter is pressed in last row.
1422 allowManualInsertColumn: false, // To prevent addition of new column when Tab is pressed in last column.
1423 about: false,
1424 secureFormulas: false,
1425 detachForUpdates: true,
1426 onselection: tp.callbacks.editor.onselection,
1427 updateTable: tp.callbacks.editor.onupdatetable,
1428 contextMenu,
1429 sorting: tp.helpers.editor.sorting,
1430 // Keep the selection when certain events occur and the table loses focus.
1431 onmoverow: tp.callbacks.editor.onmove,
1432 onmovecolumn: tp.callbacks.editor.onmove,
1433 onblur: tp.helpers.editor.reselect,
1434 onload: tp.helpers.editor.reselect, // When the table is loaded, select the top-left cell A1.
1435 onchange: tp.helpers.unsaved_changes.set,
1436 onsort: tp.callbacks.editor.onsort,
1437 } );
1438
1439 tp.helpers.options.load();
1440
1441 /*
1442 * Register click callback for the "Preview" and "Save Changes" buttons.
1443 */
1444 $( '#tablepress-page' ).addEventListener( 'click', ( event ) => {
1445 if ( ! event.target ) {
1446 return;
1447 }
1448
1449 if ( event.target.matches( '.button-show-preview' ) ) {
1450 tp.callbacks.table_preview.process( event );
1451 return;
1452 }
1453
1454 if ( event.target.matches( '.button-save-changes' ) ) {
1455 tp.callbacks.save_changes.process( event );
1456 return;
1457 }
1458
1459 if ( event.target.matches( '.button-show-help-box' ) ) {
1460 tp.callbacks.help_box.open_dialog( event );
1461 return;
1462 }
1463 } );
1464
1465 /*
1466 * Register click callbacks for the table manipulation buttons.
1467 */
1468 $( '#tablepress-manipulation-controls' ).addEventListener( 'click', ( event ) => {
1469 if ( ! event.target ) {
1470 return;
1471 }
1472
1473 /*
1474 * Events that don't require a selection.
1475 */
1476
1477 if ( event.target.matches( '.button-append' ) ) {
1478 const type = event.target.dataset.type;
1479 const $input_field = $( `#${ type }-append-number` );
1480 const num_rocs = parseInt( $input_field.value, 10 );
1481 if ( isNaN( num_rocs ) || num_rocs < 1 || num_rocs > 99999 ) {
1482 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' );
1483 window.alert( message );
1484 $input_field.focus();
1485 $input_field.select();
1486 return;
1487 }
1488
1489 tp.callbacks.append( type, num_rocs );
1490 return;
1491 }
1492
1493 /*
1494 * Events that do require a selection.
1495 */
1496
1497 if ( 'button-insert-link' === event.target.id ) {
1498 tp.callbacks.insert_link.open_dialog();
1499 return;
1500 }
1501
1502 if ( 'button-insert-image' === event.target.id ) {
1503 tp.callbacks.insert_image.open_dialog();
1504 return;
1505 }
1506
1507 if ( 'button-advanced-editor' === event.target.id ) {
1508 tp.callbacks.advanced_editor.open_dialog();
1509 return;
1510 }
1511
1512 if ( event.target.matches( '.button-insert-duplicate' ) ) {
1513 tp.callbacks.insert_duplicate( event.target.dataset.action, event.target.dataset.type );
1514 return;
1515 }
1516
1517 if ( event.target.matches( '.button-move' ) ) {
1518 if ( ! tp.helpers.move_allowed( event.target.dataset.type, event.target.dataset.direction ) ) {
1519 window.alert( __( 'You can not do this move, because you reached the border of the table.', 'tablepress' ) );
1520 return;
1521 }
1522 tp.callbacks.move( event.target.dataset.direction, event.target.dataset.type );
1523 return;
1524 }
1525
1526 if ( event.target.matches( '.button-remove' ) ) {
1527 const handling_rows = ( 'rows' === event.target.dataset.type );
1528 const num_rocs = handling_rows ? tp.editor.options.data.length : tp.editor.options.columns.length;
1529
1530 if ( num_rocs === tp.helpers.selection[ event.target.dataset.type ].length ) {
1531 const message = handling_rows ? __( 'You can not delete all table rows!', 'tablepress' ) : __( 'You can not delete all table columns!', 'tablepress' );
1532 window.alert( message );
1533 return;
1534 }
1535
1536 tp.callbacks.remove( event.target.dataset.type );
1537 return;
1538 }
1539
1540 if ( event.target.matches( '.button-merge-unmerge' ) ) {
1541 if ( tp.helpers.cell_merge_allowed( 'alert' ) ) {
1542 tp.callbacks.merge_cells();
1543 }
1544 return;
1545 }
1546
1547 if ( event.target.matches( '.button-hide-unhide' ) ) {
1548 tp.callbacks.hide_unhide( event.target.dataset.action, event.target.dataset.type );
1549 return;
1550 }
1551 } );
1552
1553 // Register callbacks for the table ID text field.
1554 const $table_id_field = $( '#table-id' );
1555 $table_id_field.addEventListener( 'input', tp.callbacks.table_id.sanitize );
1556 $table_id_field.addEventListener( 'change', tp.callbacks.table_id.change );
1557
1558 // Select Shortcode input field content when it's focussed.
1559 const $table_information_shortcode = $( '#table-information-shortcode' );
1560 if ( $table_information_shortcode ) {
1561 $table_information_shortcode.addEventListener( 'focus', function() {
1562 this.select();
1563 } );
1564 }
1565
1566 // Register callback for inserting a link into a cell after it has been constructed in the wpLink dialog.
1567 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.
1568
1569 // Register change callbacks for the table name, description, and options.
1570 [ '#table-name', '#table-description' ].forEach( ( field_id ) => $( field_id ).addEventListener( 'change', tp.helpers.unsaved_changes.set ) );
1571 const options_meta_boxes = apply_filters( 'tablepress.optionsMetaBoxes', [ '#tablepress_edit-table-options', '#tablepress_edit-datatables-features' ] );
1572 options_meta_boxes.forEach( ( meta_box_id ) => $( meta_box_id ).addEventListener( 'change', tp.helpers.options.change ) );
1573
1574 // Move all "Help" buttons inside the postbox header.
1575 document.querySelectorAll( '#tablepress-body .button-module-help' ).forEach( ( $button ) => ( $button.closest( '.postbox' ).querySelector( '.handle-actions' ).prepend( $button ) ) );
1576
1577 // Register callbacks for the screen options.
1578 const $tablepress_screen_options = $( '#tablepress-screen-options' );
1579 $tablepress_screen_options.addEventListener( 'input', tp.callbacks.screen_options.update );
1580 $tablepress_screen_options.addEventListener( 'change', tp.callbacks.screen_options.set_was_changed );
1581 $tablepress_screen_options.addEventListener( 'focusout', tp.callbacks.screen_options.save ); // Use the `focusout` event instead of `blur` as that does not bubble.
1582
1583 // Register keyboard shortcut handler.
1584 window.addEventListener( 'keydown', tp.callbacks.keyboard_shortcuts, true );
1585
1586 // Add keyboard shortcuts as title attributes to "Preview" and "Save Changes" buttons, with correct modifier key for Mac/non-Mac.
1587 const modifier_key = ( window?.navigator?.platform?.includes( 'Mac' ) ) ?
1588 _x( '', 'keyboard shortcut modifier key on a Mac keyboard', 'tablepress' ) :
1589 _x( 'Ctrl+', 'keyboard shortcut modifier key on a non-Mac keyboard', 'tablepress' );
1590 document.querySelectorAll( '.button[data-shortcut]' ).forEach( ( $button ) => {
1591 const shortcut = sprintf( $button.dataset.shortcut, modifier_key ); // eslint-disable-line @wordpress/valid-sprintf
1592 $button.title = sprintf( __( 'Keyboard Shortcut: %s', 'tablepress' ), shortcut );
1593 } );
1594
1595 // This code requires jQuery, and it must run when the DOM is ready. Therefore, move it outside of the main function.
1596 jQuery( function () {
1597 // Fix issue with wpLink input fields not being usable, when called through the "Advanced Editor". They are immediately losing focus without this.
1598 jQuery( '#wp-link' ).on( 'focus', 'input', function ( event ) {
1599 event.stopPropagation();
1600 } );
1601
1602 // 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.
1603 jQuery( 'body' ).on( 'focus', '.media-modal .media-frame-content input, .media-modal .media-frame-content textarea', function ( event ) {
1604 event.stopPropagation();
1605 } );
1606 } );
1607