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

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