PluginProbe
WP Attachments – Smarter File Management & Download Lists / 6.1
WP Attachments – Smarter File Management & Download Lists v6.1
6.1 6.0.2 6.0.3 trunk 2.0 3 3.0.1 3.0.2 3.0.3 3.1 3.1.1 3.1.2 3.1.3 3.1.4 3.2 3.2.1 3.2.2 3.2.3 3.2.4 3.3 3.4 3.5 3.5.1 3.5.2 3.5.3 All 53 releases
wp-attachments / inc / scripts / metabox.js

metabox.js in WP Attachments – Smarter File Management & Download Lists 6.1, at inc/scripts/metabox.js

676 lines 26.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 var WP_Attachments = (function($) {
2 "use strict";
3
4 var attachments = {
5 mediaFrame: null, // <-- Add this line
6
7 init: function() {
8 this.cacheElements();
9 this.detachModal();
10 this.bindEvents();
11 this.initSortable();
12 this.refreshMoveButtons();
13 this.initializeFilePreview();
14 },
15
16 // The block editor renders metaboxes inside a transformed container.
17 // A transformed ancestor becomes the containing block for
18 // position:fixed, so the dialog would only cover the metabox panel
19 // instead of the viewport. Re-parenting it to <body> fixes that.
20 detachModal: function() {
21 if (this.$previewModal.length && this.$previewModal.parent()[0] !== document.body) {
22 this.$previewModal.appendTo(document.body);
23 }
24 },
25
26 cacheElements: function() {
27 this.$container = $('#wpa-attachment-list');
28 this.$attachmentItems = $('.wpa-attachment-item');
29 this.$addMediaButton = $('.wpa_attach_file');
30 this.$previewModal = $('#wpa-preview-modal');
31 this.$previewContent = $('#wpa-preview-file');
32 this.$previewTitle = $('#wpa-preview-title');
33 this.$stats = $('#wpa-attachments-stats');
34
35 // Cache localized variables
36 this.editMediaTitle = WP_Attachments_Vars.editMedia || 'Edit Media';
37 this.youSureText = WP_Attachments_Vars.youSure || 'Are you sure you want to do this?';
38 this.confirmDeleteText = WP_Attachments_Vars.confirmDelete || 'Are you sure you want to delete this permanently?';
39 this.postID = WP_Attachments_Vars.postID || 0;
40 this.ajaxurl = WP_Attachments_Vars.ajaxurl || '';
41 this.nonce = WP_Attachments_Vars.nonce || '';
42 },
43
44 bindEvents: function() {
45 var self = this;
46
47 // Add media button click
48 $(document).on('click', '.wpa_attach_file', this.handleAddMedia.bind(this));
49
50 // Visible move controls. Dragging alone is mouse-only and the grip
51 // on its own was not discoverable.
52 $(document).on('click', '.wpa-move-up, .wpa-move-down', function (e) {
53 e.preventDefault();
54 self.moveItem(
55 $(this).closest('.wpa-attachment-item'),
56 $(this).hasClass('wpa-move-up'),
57 this
58 );
59 });
60
61 // Arrow keys on the grip do the same thing.
62 $(document).on('keydown', '.wpa-attachment-drag-handle', function (e) {
63 var isUp = (e.key === 'ArrowUp' || e.keyCode === 38);
64 var isDown = (e.key === 'ArrowDown' || e.keyCode === 40);
65 if (!isUp && !isDown) {
66 return;
67 }
68
69 e.preventDefault();
70 self.moveItem($(this).closest('.wpa-attachment-item'), isUp, this);
71 });
72
73 // Edit: open the WordPress media modal instead of leaving the page.
74 // Core saves the fields through its own save-attachment endpoint,
75 // so there is no custom handler behind this.
76 $(document).on('click', '.wpa-edit-attachment', function (e) {
77 // Let modified clicks fall through to the full edit screen.
78 if (e.which > 1 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
79 return;
80 }
81
82 var id = parseInt($(this).closest('.wpa-attachment-item').data('attachmentid'), 10);
83 if (!id || typeof wp === 'undefined' || !wp.media) {
84 return; // no JS media library: the href still works
85 }
86
87 e.preventDefault();
88 self.openEditFrame(id);
89 });
90
91 // Preview triggers. Delegated, because rows arrive via AJAX too.
92 $(document).on('click', '.wpa-preview-trigger', function(e) {
93 e.preventDefault();
94 var $trigger = $(this);
95 self.previewFile(
96 $trigger.data('url'),
97 $trigger.data('mime'),
98 $trigger.data('title'),
99 this
100 );
101 });
102
103 // Preview modal close buttons
104 $(document).on('click', '.wpa-preview-close', this.closePreviewModal.bind(this));
105
106 // Click outside preview modal to close
107 $(document).on('click', '.wpa-preview-modal', function(e) {
108 if (e.target === this) {
109 self.closePreviewModal();
110 }
111 });
112
113 // Keyboard events for modals
114 $(document).on('keydown', this.handleKeyEvents.bind(this));
115
116 // Row unattach / delete: done in place, so unsaved edits to the
117 // post survive. The href stays as the no-JS fallback.
118 $(document).on('click', '.wpa-unattach-action, .wpa-delete-action', function(e) {
119 // Modified clicks open the link as before.
120 if (e.which > 1 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
121 return;
122 }
123
124 var isDelete = $(this).hasClass('wpa-delete-action');
125 e.preventDefault();
126
127 if (!window.confirm(isDelete ? self.confirmDeleteText : self.youSureText)) {
128 return;
129 }
130
131 var id = parseInt($(this).closest('.wpa-attachment-item').data('attachmentid'), 10);
132 if (id) {
133 self.runAction(isDelete ? 'delete' : 'unattach', id);
134 }
135 });
136 },
137
138 // Unattach or delete one file through AJAX, then drop its row.
139 runAction: function(action, id) {
140 var self = this;
141 var $row = $('.wpa-attachment-item[data-attachmentid="' + id + '"]');
142
143 $row.addClass('is-busy');
144
145 $.ajax({
146 url: this.ajaxurl,
147 type: 'POST',
148 data: {
149 action: action === 'delete' ? 'wpa_delete_media' : 'wpa_unattach_media',
150 attachment_id: id,
151 post_id: this.postID,
152 nonce: this.nonce
153 },
154 success: function(response) {
155 if (!response.success || !response.data) {
156 $row.removeClass('is-busy');
157 window.alert(WP_Attachments_Vars.actionFailed || 'The file could not be changed.');
158 return;
159 }
160
161 var data = response.data;
162
163 $row.remove();
164
165 if (data.stats) {
166 $('#wpa-attachments-stats').html(data.stats);
167 }
168 if (data.empty && !$('#wpa-attachment-list .wpa-attachment-item').length) {
169 $('#wpa-attachment-list').html(data.empty);
170 }
171
172 self.cacheElements();
173 self.refreshMoveButtons();
174
175 $('#wpa-reorder-status').text(action === 'delete'
176 ? (WP_Attachments_Vars.fileDeleted || 'File deleted.')
177 : (WP_Attachments_Vars.fileUnattached || 'File unattached.'));
178 },
179 error: function(xhr) {
180 $row.removeClass('is-busy');
181 // A JSON error is a refusal (permissions, file no longer
182 // attached); anything else is a network or server problem.
183 window.alert(xhr.responseJSON
184 ? (WP_Attachments_Vars.actionFailed || 'The file could not be changed.')
185 : (WP_Attachments_Vars.requestFailed || 'The request failed. Please try again.'));
186 }
187 });
188 },
189
190 // Media modal scoped to a single attachment, for editing its details.
191 openEditFrame: function (attachmentId) {
192 var self = this;
193
194 this.editFrames = this.editFrames || {};
195
196 if (this.editFrames[attachmentId]) {
197 this.editFrames[attachmentId].open();
198 return;
199 }
200
201 var model = wp.media.attachment(attachmentId);
202
203 var frame = wp.media({
204 title: this.editMediaTitle,
205 // Restrict the library to this one file so the modal opens
206 // straight on its details instead of the whole collection.
207 library: { post__in: [attachmentId] },
208 button: { text: WP_Attachments_Vars.doneEditing || 'Done' },
209 multiple: false
210 });
211
212 frame.on('open', function () {
213 model.fetch();
214 frame.state().get('selection').add(model);
215 });
216
217 // Core writes each field as it changes, so mirror it back into the
218 // row as the user types rather than waiting for the modal to close.
219 model.on('change:title', function (m) {
220 self.updateRowTitle(attachmentId, m.get('title'));
221 });
222
223 frame.on('select close', function () {
224 self.updateRowTitle(attachmentId, model.get('title'));
225 });
226
227 this.editFrames[attachmentId] = frame;
228 frame.open();
229 },
230
231 // Keep the row in step with a title edited in the media modal.
232 updateRowTitle: function (attachmentId, title) {
233 if (typeof title !== 'string') {
234 return;
235 }
236
237 var $row = $('.wpa-attachment-item[data-attachmentid="' + attachmentId + '"]');
238 if (!$row.length) {
239 return;
240 }
241
242 var display = title.length ? title : (WP_Attachments_Vars.noTitle || '(no title)');
243
244 // .attr() alone is not enough: jQuery caches data attributes, and
245 // the preview handler reads them through .data().
246 $row.attr('data-title', title).data('title', title);
247 $row.find('.wpa-attachment-title a').text(display).attr('title', display);
248
249 var $trigger = $row.find('.wpa-preview-trigger');
250 if ($trigger.length) {
251 $trigger.attr('data-title', title).data('title', title);
252
253 var template = WP_Attachments_Vars.previewLabel || 'Preview %s';
254 $trigger.find('.screen-reader-text').text(template.replace('%s', display));
255 }
256 },
257
258 // Move one row up or down. Shared by the buttons and the arrow keys.
259 moveItem: function ($item, up, focusEl) {
260 var $sibling = up
261 ? $item.prev('.wpa-attachment-item')
262 : $item.next('.wpa-attachment-item');
263
264 if (!$sibling.length) {
265 return false;
266 }
267
268 if (up) {
269 $item.insertBefore($sibling);
270 } else {
271 $item.insertAfter($sibling);
272 }
273
274 this.refreshMoveButtons();
275
276 // Moving the node in the DOM drops focus in some browsers. If the
277 // control that was used just became disabled (the row reached an
278 // end), fall back to the grip so focus never lands on <body>.
279 if (focusEl) {
280 if (focusEl.disabled) {
281 $item.find('.wpa-attachment-drag-handle').trigger('focus');
282 } else {
283 focusEl.focus();
284 }
285 }
286
287 this.announcePosition($item);
288 this.handleReorder();
289
290 return true;
291 },
292
293 // Grey out the moves that would do nothing.
294 refreshMoveButtons: function () {
295 var $items = $('#wpa-attachment-list').find('.wpa-attachment-item');
296 var last = $items.length - 1;
297
298 $items.each(function (i) {
299 $(this).find('.wpa-move-up').prop('disabled', i === 0);
300 $(this).find('.wpa-move-down').prop('disabled', i === last);
301 });
302 },
303
304 // Tell screen readers where the row ended up.
305 announcePosition: function ($item) {
306 var $status = $('#wpa-reorder-status');
307 if (!$status.length) {
308 return;
309 }
310
311 var $all = $('#wpa-attachment-list').find('.wpa-attachment-item');
312 var position = $all.index($item) + 1;
313 var template = WP_Attachments_Vars.movedTo || 'Moved to position %1$s of %2$s';
314
315 $status.text(
316 template.replace('%1$s', position).replace('%2$s', $all.length)
317 );
318 },
319
320 initSortable: function() {
321 var self = this;
322
323 if (!this.$container.length) {
324 return;
325 }
326
327 // Already initialised: just let jQuery UI pick up new items.
328 // Re-running .sortable() would stack duplicate instances, and each
329 // one would fire its own reorder request.
330 if (this.$container.hasClass('ui-sortable')) {
331 this.$container.sortable('refresh');
332 return;
333 }
334
335 this.$container.sortable({
336 items: '.wpa-attachment-item',
337 handle: '.wpa-attachment-drag-handle',
338 cursor: 'move',
339 opacity: 0.7,
340 placeholder: 'wpa-attachment-item ui-sortable-placeholder',
341 forcePlaceholderSize: true,
342 tolerance: 'pointer',
343 containment: 'parent',
344 // jQuery UI's default cancel selector is
345 // "input,textarea,button,select,option". The drag handle is a
346 // <button> (so it is keyboard reachable), which that default
347 // silently refuses to drag. Drags can only start on `handle`
348 // anyway, so dropping "button" here is safe.
349 cancel: 'input, textarea, select, option',
350 start: function(e, ui) {
351 ui.item.addClass('ui-sortable-helper');
352 ui.placeholder.height(ui.item.height());
353 },
354 stop: function(e, ui) {
355 ui.item.removeClass('ui-sortable-helper');
356 },
357 // 'update' fires once, and only when the order actually changed.
358 update: function() {
359 self.refreshMoveButtons();
360 self.handleReorder();
361 },
362 change: function(e, ui) {
363 // Visual feedback during drag
364 ui.placeholder.addClass('ui-sortable-placeholder-active');
365 }
366 });
367 },
368
369 initializeFilePreview: function() {
370 // Make preview functionality globally available
371 window.wpaPreviewFile = this.previewFile.bind(this);
372 window.wpaClosePreviewModal = this.closePreviewModal.bind(this);
373 },
374
375 handleAddMedia: function(e) {
376 e.preventDefault();
377
378 var self = this;
379
380 // Use the object property, not a local variable
381 if (this.mediaFrame) {
382 this.mediaFrame.open();
383 return;
384 }
385
386 this.mediaFrame = wp.media({
387 title: WP_Attachments_Vars.mediaFrameTitle || 'Add Media Attachments',
388 button: {
389 text: WP_Attachments_Vars.mediaFrameButton || 'Attach to Post'
390 },
391 multiple: true
392 });
393
394 this.mediaFrame.on('select', function() {
395 var selection = self.mediaFrame.state().get('selection');
396 var postId = parseInt(self.postID);
397
398 if (!postId) {
399 window.alert(WP_Attachments_Vars.saveFirst || 'Please save this content before adding attachments.');
400 return;
401 }
402
403 selection.each(function(attachment) {
404 // Try to get parent ID from multiple possible locations
405 var attributes = attachment.attributes;
406 var currentParent = parseInt(attributes.uploadedTo || attributes.parent || 0);
407 var attachmentTitle = attributes.title || 'this file';
408 var proceed = true;
409
410 // If file has a parent and it's not the current post
411 // Note: currentParent might be a string like "123" or "0"
412 if (currentParent > 0 && currentParent !== postId) {
413 var parentTitle = attributes.uploadedToTitle || (attributes.parentObj ? attributes.parentObj.post_title : 'another content');
414
415 var warning = WP_Attachments_Vars.reattachWarning ||
416 'The file "%1$s" is already attached to "%2$s" (ID: %3$s).';
417
418 proceed = window.confirm(
419 warning
420 .replace('%1$s', attachmentTitle)
421 .replace('%2$s', parentTitle)
422 .replace('%3$s', currentParent)
423 );
424 }
425
426 if (proceed) {
427 self.attachFileToPost(attachment.id, postId);
428 }
429 });
430 });
431
432 this.mediaFrame.open();
433 },
434
435 attachFileToPost: function(attachmentId, postId) {
436 var self = this;
437
438 $.ajax({
439 url: this.ajaxurl,
440 type: 'POST',
441 data: {
442 action: 'wpa_attach_media', // renamed from ij_attach_media
443 attachment_id: attachmentId,
444 post_id: postId,
445 nonce: this.nonce
446 },
447 success: function(response) {
448 if (response.success && response.data && response.data.html) {
449 // Re-query: the editor may have re-rendered the metabox
450 // since the last cache.
451 self.$container = $('#wpa-attachment-list');
452 if (!self.$container.length) {
453 return;
454 }
455
456 // Remove "no attachments" placeholder if present
457 $('.wpa-no-attachments').remove();
458
459 // Append new attachment to the list
460 var $newItem = $(response.data.html);
461 self.$container.append($newItem.hide().fadeIn(400));
462
463 // Keep the counters in sync without a page reload
464 if (response.data.stats) {
465 $('#wpa-attachments-stats').html(response.data.stats);
466 }
467
468 // Re-cache elements and let sortable pick up the new row
469 self.cacheElements();
470 self.initSortable();
471 self.refreshMoveButtons();
472 // Persist the new order
473 self.handleReorder();
474 } else {
475 console.error('Failed to attach media:', response.data);
476 }
477 },
478 error: function(xhr, status, error) {
479 console.error('AJAX Error:', error);
480 }
481 });
482 },
483
484 previewFile: function(url, mimeType, title, trigger) {
485 if (!url || !mimeType) return;
486
487 // Remember what opened the modal so focus can go back there.
488 this.lastTrigger = trigger || null;
489
490 this.$previewTitle.text(title || WP_Attachments_Vars.previewFallback || 'File Preview');
491 this.$previewContent.empty();
492
493 var $previewElement;
494
495 // Handle different file types
496 if (mimeType.startsWith('image/')) {
497 $previewElement = $('<img>').attr({
498 src: url,
499 alt: title,
500 style: 'max-width: 100%; max-height: 70vh; object-fit: contain;'
501 });
502 } else if (mimeType.startsWith('video/')) {
503 $previewElement = $('<video controls>').attr({
504 src: url,
505 style: 'max-width: 100%; max-height: 70vh;'
506 }).prop('preload', 'metadata');
507 } else if (mimeType.startsWith('audio/')) {
508 $previewElement = $('<audio controls>').attr({
509 src: url,
510 style: 'width: 100%;'
511 }).prop('preload', 'metadata');
512 } else if (mimeType === 'application/pdf') {
513 $previewElement = $('<iframe>').attr({
514 src: url + '#toolbar=1&navpanes=1&scrollbar=1',
515 style: 'width: 100%; height: 70vh; border: none;'
516 });
517 } else if (mimeType.startsWith('text/') || mimeType.includes('json') || mimeType.includes('xml')) {
518 // For text files, try to load and display content
519 $previewElement = $('<div>').addClass('text-preview').text('Loading...');
520
521 $.get(url)
522 .done(function(data) {
523 $previewElement.html('<pre style="background: #f1f1f1; padding: 15px; border-radius: 4px; overflow: auto; max-height: 60vh;">' +
524 $('<div>').text(data).html() + '</pre>');
525 })
526 .fail(function() {
527 $previewElement.html('<p>Unable to preview this text file. <a href="' + url + '" target="_blank">Download instead</a></p>');
528 });
529 } else {
530 // For other file types, offer a download link.
531 // Built with jQuery rather than string concatenation so the URL
532 // can never break out of the attribute.
533 $previewElement = $('<div>').addClass('file-preview-placeholder')
534 .css({ textAlign: 'center', padding: '40px 20px' });
535
536 $previewElement.append(
537 $('<div>').addClass('dashicons dashicons-media-default')
538 .css({ fontSize: '48px', width: '48px', height: '48px', color: '#c3c4c7' })
539 );
540 $previewElement.append(
541 $('<h3>').text(WP_Attachments_Vars.previewUnavailable || 'Preview not available')
542 );
543 $previewElement.append(
544 $('<a>').addClass('button button-primary')
545 .attr({ href: url, target: '_blank', rel: 'noopener noreferrer' })
546 .text(WP_Attachments_Vars.downloadFile || 'Download file')
547 );
548 }
549
550 this.$previewContent.append($previewElement);
551 this.openPreviewModal();
552 },
553
554 openPreviewModal: function() {
555 this.$previewModal.addClass('is-open').attr('aria-hidden', 'false');
556 $('body').addClass('wpa-modal-open');
557
558 var $close = this.$previewModal.find('.wpa-preview-close');
559 if ($close.length) {
560 $close.trigger('focus');
561 }
562 },
563
564 closePreviewModal: function() {
565 if (!this.$previewModal || !this.$previewModal.hasClass('is-open')) {
566 return;
567 }
568
569 this.$previewModal.removeClass('is-open').attr('aria-hidden', 'true');
570 $('body').removeClass('wpa-modal-open');
571
572 // Stop playback, then drop the nodes so nothing keeps buffering.
573 this.$previewContent.find('video, audio').each(function() {
574 if (this.pause) this.pause();
575 this.currentTime = 0;
576 });
577 this.$previewContent.empty();
578
579 if (this.lastTrigger) {
580 $(this.lastTrigger).trigger('focus');
581 this.lastTrigger = null;
582 }
583 },
584
585 handleKeyEvents: function(e) {
586 // Only act while the modal is actually open.
587 if (!this.$previewModal || !this.$previewModal.hasClass('is-open')) {
588 return;
589 }
590
591 if (e.key === 'Escape' || e.keyCode === 27) {
592 this.closePreviewModal();
593 return;
594 }
595
596 if (e.key === 'Tab' || e.keyCode === 9) {
597 this.trapFocus(e);
598 }
599 },
600
601 // Keep Tab inside the dialog while it is open.
602 trapFocus: function(e) {
603 var $focusable = this.$previewModal
604 .find('a[href], button:not([disabled]), video[controls], audio[controls], iframe, [tabindex]:not([tabindex="-1"])')
605 .filter(':visible');
606
607 if (!$focusable.length) {
608 return;
609 }
610
611 var first = $focusable.first()[0];
612 var last = $focusable.last()[0];
613
614 if (e.shiftKey && document.activeElement === first) {
615 e.preventDefault();
616 last.focus();
617 } else if (!e.shiftKey && document.activeElement === last) {
618 e.preventDefault();
619 first.focus();
620 }
621 },
622
623 handleReorder: function() {
624 var attachmentIds = [];
625
626 this.$container.find('.wpa-attachment-item').each(function() {
627 var id = $(this).data('attachmentid');
628 if (id) {
629 attachmentIds.push(id);
630 }
631 });
632
633 if (attachmentIds.length === 0) return;
634
635 // Send new order to server
636 $.ajax({
637 url: this.ajaxurl,
638 type: 'POST',
639 data: {
640 action: 'wpa_realign', // renamed from ij_realign
641 alignment: attachmentIds,
642 nonce: this.nonce
643 },
644 success: function(response) {
645 if (!response.success) {
646 console.error('Failed to reorder attachments:', response.data);
647 }
648 },
649 error: function(xhr, status, error) {
650 console.error('AJAX Error during reorder:', error);
651 }
652 });
653 }
654 };
655
656 // Initialize when document is ready
657 $(document).ready(function() {
658 if (!window.WP_Attachments_initialized) {
659 attachments.init();
660 window.WP_Attachments_initialized = true;
661 }
662
663 // Re-initialize after AJAX requests that might add new content
664 $(document).ajaxComplete(function(event, xhr, settings) {
665 if (settings.url && settings.url.indexOf('admin-ajax.php') !== -1) {
666 attachments.cacheElements();
667 }
668 });
669 });
670
671 // Make the object globally available for debugging
672 window.WP_Attachments = attachments;
673
674 return attachments;
675
676 })(jQuery);