PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.3
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.3
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / assets / js / media-library-enhanced.js

media-library-enhanced.js in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.3, at assets/js/media-library-enhanced.js

612 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Desktop Mode — Media Library drag-and-drop enhancement.
3 *
4 * Injects draggable=true on every .attachment tile in the WordPress
5 * Media Library (grid view AND modal view) and wires a dragstart
6 * handler that populates DataTransfer with multiple MIME types so the
7 * drag works in many drop targets:
8 *
9 * text/plain — the attachment URL
10 * text/uri-list — same URL, standards-compliant
11 * text/html — <img> tag for images,
12 * <a> tag for other files,
13 * so rich text editors
14 * (TinyMCE, contenteditable)
15 * natively accept the drop
16 * application/x-wp-media-attachment — JSON blob with id, url,
17 * title, alt, mime, sizes —
18 * for WP-aware drop zones
19 * that want the full record
20 *
21 * The script is a vanilla IIFE with no build step. It is intentionally
22 * defensive:
23 *
24 * - runs only when wp.media exists,
25 * - idempotent (each tile is enhanced at most once),
26 * - uses a MutationObserver so tiles added later (user switches
27 * folder, scrolls, opens a different media frame) are enhanced
28 * too,
29 * - never removes or interferes with WordPress's own click / focus
30 * handlers on the tile.
31 *
32 * @since 0.14.0
33 */
34
35 ( function () {
36 'use strict';
37
38 // `start()` runs unconditionally — `wp.media` is only required by
39 // the grid + detail enhancers (those selectors won't match on
40 // pages that don't load the Backbone media library). The list-
41 // view delegated handler uses DOM-only fallbacks (row markup +
42 // thumbnail src + anchor href) and works without `wp.media`,
43 // which is the case on `upload.php?mode=list` — the list table
44 // is a server-rendered `WP_List_Table`, no media-views.js bundle.
45 //
46 // If `wp.media` arrives later (e.g. a modal opens and pulls it
47 // in), the MutationObserver picks up the freshly-rendered grid
48 // tiles and runs `enhance()` on them — at which point the
49 // grid-side `wp.media.attachment()` lookup is safe.
50 start();
51
52 // Flag set during our own attachment drags so the capture-phase
53 // blocker below can distinguish between "user is dragging a file
54 // from the OS" (which the WP uploader should handle) and "user is
55 // dragging a pre-existing attachment tile" (which the uploader
56 // must NOT intercept — otherwise it tries to re-upload it).
57 var dragInProgress = false;
58
59 // CSS selectors for the two enhancement paths:
60 // - GRID_SELECTOR matches the small tiles in the library grid and
61 // in the media modal's left-hand attachments browser.
62 // - DETAIL_SELECTOR matches the BIG preview in the right-hand
63 // details sidebar of the media modal, and the dedicated single-
64 // attachment edit page at `upload.php?item=ID`. WP renders the
65 // image inside a `.thumbnail` wrapper that has its own click
66 // handlers (swap-into-edit, set-as-featured, etc.) — the user
67 // reported that without explicit enhancement here the browser
68 // refuses to start a native drag from the big preview.
69 var GRID_SELECTOR = '.attachment';
70 var DETAIL_SELECTOR = '.attachment-details, .edit-attachment-frame';
71 // `upload.php?mode=list` renders attachments inside `table.media`.
72 // Each `tbody tr[id^="post-"]` is one attachment row. Scoped to
73 // the `.media` table class so we don't enhance unrelated WP list
74 // tables (Posts, Pages, …) that share the same row-id convention.
75 var LIST_SELECTOR = 'table.media tbody tr[id^="post-"]';
76
77 function start() {
78 // Enhance whatever's already on the page.
79 document.querySelectorAll( GRID_SELECTOR ).forEach( enhance );
80 document.querySelectorAll( DETAIL_SELECTOR ).forEach( enhanceDetail );
81 // List view uses event delegation (no per-row wiring) — install
82 // once and the dragstart handler picks up rows that arrive
83 // later (pagination, search, mode-switch).
84 installListDelegation();
85
86 // Watch for new tiles + detail panes + list rows. Grid is a
87 // Backbone collection view that appends tiles on scroll /
88 // filter / modal open; the detail sidebar swaps content on
89 // every attachment pick; the list view refreshes rows on
90 // pagination / search. MutationObserver on the body catches
91 // all of them.
92 var observer = new MutationObserver( function ( mutations ) {
93 for ( var i = 0; i < mutations.length; i++ ) {
94 var added = mutations[ i ].addedNodes;
95 for ( var j = 0; j < added.length; j++ ) {
96 var node = added[ j ];
97 if ( node.nodeType !== 1 ) {
98 continue;
99 }
100 if ( node.matches && node.matches( GRID_SELECTOR ) ) {
101 enhance( node );
102 }
103 if ( node.matches && node.matches( DETAIL_SELECTOR ) ) {
104 enhanceDetail( node );
105 }
106 if ( node.matches && node.matches( LIST_SELECTOR ) ) {
107 enhanceListRow( node );
108 }
109 if ( node.querySelectorAll ) {
110 node.querySelectorAll( GRID_SELECTOR ).forEach( enhance );
111 node.querySelectorAll( DETAIL_SELECTOR ).forEach( enhanceDetail );
112 node.querySelectorAll( LIST_SELECTOR ).forEach( enhanceListRow );
113 }
114 }
115 }
116 } );
117 observer.observe( document.body, { childList: true, subtree: true } );
118
119 installUploaderBlock();
120 }
121
122 /**
123 * Install capture-phase interceptors that stop drag events from
124 * reaching WordPress's Plupload dropzones while an attachment
125 * drag is in flight. Plupload doesn't check DataTransfer.types
126 * for the "Files" entry before claiming a drop, so without this
127 * it treats our attachment drag as a new-file upload attempt.
128 */
129 function installUploaderBlock() {
130 // Every uploader dropzone class WP core uses. `.drag-drop-area`
131 // is the text-and-icon panel inside the full-screen overlay;
132 // `.uploader-window` is the overlay itself; `.uploader-inline`
133 // and `.uploader-editor` cover the modal and classic-editor
134 // variants respectively.
135 var UPLOADER_SELECTOR = [
136 '.uploader-window',
137 '.uploader-inline',
138 '.uploader-editor',
139 '.drag-drop-area',
140 '.wp-uploader'
141 ].join( ',' );
142
143 var block = function ( e ) {
144 if ( ! dragInProgress ) {
145 return;
146 }
147 var t = e.target;
148 if ( ! t || typeof t.closest !== 'function' ) {
149 return;
150 }
151 if ( t.closest( UPLOADER_SELECTOR ) ) {
152 // Capture phase — fires before the uploader's own
153 // handler, so stopImmediatePropagation means the
154 // uploader never sees the event and therefore never
155 // calls preventDefault() to claim the drop.
156 e.stopImmediatePropagation();
157 if ( e.type === 'drop' || e.type === 'dragend' ) {
158 e.preventDefault();
159 }
160 }
161 };
162
163 document.addEventListener( 'dragenter', block, true );
164 document.addEventListener( 'dragover', block, true );
165 document.addEventListener( 'dragleave', block, true );
166 document.addEventListener( 'drop', block, true );
167
168 // Inject a tiny stylesheet that hides the uploader overlay
169 // while our drag is active. Belt-and-braces: even if the
170 // capture listener above misses an event, the UI won't flash
171 // the "Drop files here" overlay — nothing visible to signal
172 // to the user that a re-upload is about to happen.
173 var style = document.createElement( 'style' );
174 style.textContent =
175 'body.desktop-mode-dragging-attachment .uploader-window,' +
176 'body.desktop-mode-dragging-attachment .uploader-window-content,' +
177 'body.desktop-mode-dragging-attachment .uploader-editor-content,' +
178 'body.desktop-mode-dragging-attachment .wp-uploader {' +
179 ' display: none !important;' +
180 ' pointer-events: none !important;' +
181 '}';
182 document.head.appendChild( style );
183 }
184
185 /**
186 * Make a single .attachment tile draggable. Idempotent.
187 *
188 * @param {HTMLElement} el The .attachment element.
189 */
190 function enhance( el ) {
191 if ( el.dataset.desktopModeDraggable === '1' ) {
192 return;
193 }
194 el.dataset.desktopModeDraggable = '1';
195 el.setAttribute( 'draggable', 'true' );
196
197 el.addEventListener( 'dragstart', function ( e ) {
198 var id = parseInt( el.getAttribute( 'data-id' ) || el.dataset.id || '0', 10 );
199 if ( ! id ) {
200 return;
201 }
202 var model = wp.media.attachment( id );
203 var a = ( model && model.attributes ) ? model.attributes : {};
204 var url = resolveOriginalUrl( a, scrapeUrl( el ) );
205 var title = a.title || scrapeTitle( el );
206 if ( ! url ) {
207 e.preventDefault();
208 return;
209 }
210 populateDragTransfer( e, el, {
211 id: id,
212 url: url,
213 title: title,
214 alt: a.alt || title,
215 mime: a.mime || a.mimeType || '',
216 sizes: a.sizes || {},
217 } );
218 } );
219
220 el.addEventListener( 'dragend', onDragEnd );
221 }
222
223 /**
224 * Detail-view path — the BIG preview shown in the media modal's
225 * right-hand sidebar AND the dedicated single-attachment edit page
226 * at `upload.php?item=ID`. The grid-tile `enhance()` selector
227 * (`.attachment`) doesn't reach these containers, so without this
228 * companion the user can drag from the library grid but not from
229 * the detail view.
230 *
231 * The container itself is made `draggable=true`. Setting it on the
232 * wrapper rather than the inner `<img>` is intentional: the inner
233 * thumbnail has WP click handlers (swap-into-edit, set-as-featured)
234 * that can call `preventDefault` on `mousedown` and abort the
235 * browser's native image-drag before it gets a chance to start.
236 * Hoisting `draggable` to the parent gives us a clean handle and
237 * the `<img>` inside acts as the drag image.
238 *
239 * @param {HTMLElement} el The `.attachment-details` or
240 * `.edit-attachment-frame` container.
241 */
242 function enhanceDetail( el ) {
243 if ( el.dataset.desktopModeDraggable === '1' ) {
244 return;
245 }
246 el.dataset.desktopModeDraggable = '1';
247 el.setAttribute( 'draggable', 'true' );
248
249 el.addEventListener( 'dragstart', function ( e ) {
250 var id = resolveDetailId( el );
251 var model = id ? wp.media.attachment( id ) : null;
252 var a = ( model && model.attributes ) ? model.attributes : {};
253
254 var img = el.querySelector(
255 '.thumbnail img, .attachment-media-view img, .details-image, img'
256 );
257 var fallbackUrl = img && ( img.currentSrc || img.src ) || '';
258 var url = resolveOriginalUrl( a, fallbackUrl );
259 if ( ! url ) {
260 e.preventDefault();
261 return;
262 }
263 var title = a.title
264 || scrapeDetailTitle( el )
265 || ( img && ( img.alt || img.title ) )
266 || '';
267
268 populateDragTransfer( e, el, {
269 id: id || 0,
270 url: url,
271 title: title,
272 alt: a.alt || title,
273 mime: a.mime || a.mimeType || guessMimeFromUrl( url ),
274 sizes: a.sizes || {},
275 } );
276 } );
277
278 el.addEventListener( 'dragend', onDragEnd );
279 }
280
281 /**
282 * List-view path — `upload.php?mode=list` renders attachments in
283 * a `wp-list-table` instead of the grid. Each row carries the
284 * attachment id in its DOM id (`post-{id}`) and the thumbnail
285 * lives inside `<a><span class="media-icon"><img></span></a>`
286 * in the title cell. Without this companion the user can drag
287 * from the grid but the list-view drag is silently lost.
288 *
289 * Per-row enhancement loses against the browser's default
290 * native drag — `<a>` and `<img>` are both `draggable=true` by
291 * default, and Chromium's behaviour for "the innermost
292 * draggable element wins" is fragile under runtime
293 * `draggable=false` attribute changes. The delegated
294 * `dragstart` listener below sidesteps the whole "which element
295 * owns the drag" question: the event bubbles from wherever the
296 * browser decides to start it, and we re-populate the transfer
297 * with our payload + post the bridge handshake. Whatever the
298 * browser's default drag put in `DataTransfer` (the img's URL,
299 * the anchor's href) gets overwritten by our `setData` calls in
300 * the same event.
301 *
302 * Mounted ONCE per page (sentinel-guarded) from `start()`. No
303 * per-row work, no MutationObserver bookkeeping for list rows.
304 */
305 var LIST_DELEGATION_INSTALLED = false;
306 function installListDelegation() {
307 if ( LIST_DELEGATION_INSTALLED ) {
308 return;
309 }
310 LIST_DELEGATION_INSTALLED = true;
311 document.body.addEventListener( 'dragstart', function ( e ) {
312 var target = e.target;
313 if ( ! target || ! target.closest ) {
314 return;
315 }
316 var row = target.closest( LIST_SELECTOR );
317 if ( ! row ) {
318 return;
319 }
320 var id = parseInt( ( row.id || '' ).replace( /^post-/, '' ), 10 );
321 if ( ! id ) {
322 return;
323 }
324 // `wp.media` isn't loaded on `upload.php?mode=list` (the
325 // list table is server-rendered, no Backbone media bundle).
326 // Skip the model lookup when it's absent — the DOM
327 // fallbacks below (`rowImg.src`, `rowAnchor.href`,
328 // `rowAnchor.textContent`) give us everything the
329 // receiver needs to insert a `core/image` block.
330 var model =
331 window.wp &&
332 window.wp.media &&
333 typeof window.wp.media.attachment === 'function'
334 ? window.wp.media.attachment( id )
335 : null;
336 var a = ( model && model.attributes ) ? model.attributes : {};
337
338 // Fallbacks — the list view doesn't pre-warm wp.media's
339 // attachment cache the way the grid does, so any of
340 // `model.url` / `model.title` may be missing on first
341 // drag. Pull from the row markup instead.
342 var rowImg = row.querySelector( '.media-icon img, img' );
343 var rowAnchor = row.querySelector( 'a.row-title, .column-title a' );
344 var url = resolveOriginalUrl( a, '' )
345 || ( rowImg && ( rowImg.currentSrc || rowImg.src ) )
346 || ( rowAnchor && rowAnchor.getAttribute( 'href' ) )
347 || '';
348 if ( ! url ) {
349 return;
350 }
351 var title = a.title
352 || ( rowAnchor && rowAnchor.textContent.trim() )
353 || '';
354
355 populateDragTransfer( e, row, {
356 id: id,
357 url: url,
358 title: title,
359 alt: a.alt || title,
360 mime: a.mime || a.mimeType || guessMimeFromUrl( url ),
361 sizes: a.sizes || {},
362 } );
363 }, true );
364
365 // `dragend` cleanup runs once per drag — same delegation.
366 document.body.addEventListener( 'dragend', function ( e ) {
367 var target = e.target;
368 if ( ! target || ! target.closest ) {
369 return;
370 }
371 if ( ! target.closest( LIST_SELECTOR ) ) {
372 return;
373 }
374 onDragEnd();
375 }, true );
376 }
377
378 /** Compat shim — the start() walk still calls enhanceListRow. */
379 function enhanceListRow() {
380 installListDelegation();
381 }
382
383 /**
384 * Shared tail of every dragstart handler: arm the uploader-block
385 * interceptor, populate DataTransfer with text/uri-list + text/html
386 * + the WP-aware custom MIME, and postMessage the payload up to the
387 * parent shell so the cross-iframe bridge has it.
388 *
389 * @param {DragEvent} e
390 * @param {HTMLElement} sourceEl The element being dragged (for
391 * the drag image fallback).
392 * @param {{id:number,url:string,title:string,alt:string,
393 * mime:string,sizes:object,thumbnailUrl?:string}} record
394 */
395 function populateDragTransfer( e, sourceEl, record ) {
396 dragInProgress = true;
397 document.body.classList.add( 'desktop-mode-dragging-attachment' );
398
399 var url = record.url;
400 var title = record.title;
401 var alt = record.alt || title;
402 var mime = record.mime || '';
403 var thumbnailUrl = record.thumbnailUrl
404 || ( record.sizes && record.sizes.thumbnail && record.sizes.thumbnail.url )
405 || url;
406
407 try {
408 e.dataTransfer.setData( 'text/plain', url );
409 e.dataTransfer.setData( 'text/uri-list', url );
410
411 if ( mime.indexOf( 'image/' ) === 0 ) {
412 e.dataTransfer.setData(
413 'text/html',
414 '<img src="' + escapeAttr( url ) + '" alt="' + escapeAttr( alt ) + '" />'
415 );
416 } else {
417 e.dataTransfer.setData(
418 'text/html',
419 '<a href="' + escapeAttr( url ) + '">' + escapeHtml( title || url ) + '</a>'
420 );
421 }
422
423 e.dataTransfer.setData(
424 'application/x-wp-media-attachment',
425 JSON.stringify( {
426 id: record.id,
427 url: url,
428 title: title,
429 alt: alt,
430 mime: mime,
431 sizes: record.sizes || {},
432 } )
433 );
434
435 e.dataTransfer.effectAllowed = 'copy';
436
437 var thumb = sourceEl.querySelector( 'img' );
438 if ( thumb && thumb.complete && thumb.naturalWidth > 0 ) {
439 e.dataTransfer.setDragImage( thumb, thumb.width / 2, thumb.height / 2 );
440 }
441 } catch ( err ) {
442 // setData can throw in older browsers or under hostile CSP.
443 }
444
445 try {
446 if ( window.parent && window.parent !== window ) {
447 window.parent.postMessage( {
448 type: 'desktop-mode-drag-start',
449 payload: {
450 id: record.id,
451 url: url,
452 title: title,
453 alt: alt,
454 mime: mime,
455 sizes: record.sizes || {},
456 thumbnailUrl: thumbnailUrl,
457 },
458 }, window.location.origin );
459 }
460 } catch ( postErr ) {
461 // Cross-origin parent or sandboxed frame — the drag still
462 // works via native DataTransfer.
463 }
464 }
465
466 function onDragEnd() {
467 dragInProgress = false;
468 document.body.classList.remove( 'desktop-mode-dragging-attachment' );
469 try {
470 if ( window.parent && window.parent !== window ) {
471 window.parent.postMessage(
472 { type: 'desktop-mode-drag-end' },
473 window.location.origin
474 );
475 }
476 } catch ( err ) { /* swallow */ }
477 }
478
479 /**
480 * Resolve the attachment id for a detail-view container. WP exposes
481 * it in several places depending on the surface:
482 *
483 * - Modal sidebar: `<div class="attachment-details" data-id="N">`
484 * - Single-attachment page: `?item=N` in the URL, or a hidden
485 * `#post_ID` input emitted by the post editor.
486 *
487 * Returns 0 when no id can be found — `populateDragTransfer`
488 * tolerates id=0 and still ships a working drag using the
489 * scraped URL.
490 */
491 function resolveDetailId( el ) {
492 var raw = el.getAttribute( 'data-id' )
493 || ( el.dataset && el.dataset.id )
494 || '';
495 var n = parseInt( raw, 10 );
496 if ( n ) return n;
497
498 try {
499 var q = new URLSearchParams( window.location.search );
500 n = parseInt( q.get( 'item' ) || q.get( 'post' ) || '0', 10 );
501 if ( n ) return n;
502 } catch ( err ) { /* old browser */ }
503
504 var hidden = document.getElementById( 'post_ID' );
505 if ( hidden && hidden.value ) {
506 n = parseInt( hidden.value, 10 );
507 if ( n ) return n;
508 }
509 return 0;
510 }
511
512 function scrapeDetailTitle( el ) {
513 var input = el.querySelector( '[data-setting="title"] input, #title' );
514 if ( input && input.value ) return input.value;
515 var filename = el.querySelector( '.filename, .filename .file' );
516 return filename ? filename.textContent.trim() : '';
517 }
518
519 /**
520 * Resolve the most-original URL for an attachment, in order:
521 *
522 * 1. `originalImageURL` from the model — WP 5.3+ exposes this
523 * when the uploaded image was big enough to trigger the
524 * `-scaled` derivative. It points at the un-scaled original.
525 * 2. The model's `url`, with WP-generated suffixes stripped:
526 * `-WxH` size variants AND the `-scaled` marker (both, in
527 * either order, anchored to the extension).
528 * 3. The DOM-scraped fallback (the thumbnail src), with the
529 * same suffix normalisation.
530 *
531 * Returns '' when nothing is available.
532 *
533 * @param {object} attrs The attachment model's `attributes`.
534 * @param {string} fallback URL scraped from the DOM (thumbnail).
535 * @return {string}
536 */
537 function resolveOriginalUrl( attrs, fallback ) {
538 if ( attrs && attrs.originalImageURL ) {
539 return attrs.originalImageURL;
540 }
541 var candidate = ( attrs && attrs.url ) || fallback || '';
542 return stripSizeSuffix( candidate );
543 }
544
545 /**
546 * Strip `-WxH` (e.g. `-300x167`) and `-scaled` suffixes immediately
547 * before the file extension, preserving any query string / fragment.
548 *
549 * foo-300x167.jpg → foo.jpg
550 * foo-scaled.jpg → foo.jpg
551 * foo-300x167-scaled.jpg → foo.jpg
552 * foo.jpg?ver=1 → foo.jpg?ver=1
553 * foo.jpg → foo.jpg
554 */
555 function stripSizeSuffix( url ) {
556 if ( ! url ) return url;
557 return url.replace(
558 /(-\d+x\d+)?(-scaled)?(\.[a-z0-9]+)(\?[^#]*)?(#.*)?$/i,
559 function ( _m, _wh, _sc, ext, query, hash ) {
560 return ext + ( query || '' ) + ( hash || '' );
561 }
562 );
563 }
564
565 function guessMimeFromUrl( url ) {
566 var m = /\.([a-z0-9]+)(?:\?|#|$)/i.exec( url || '' );
567 var ext = m ? m[ 1 ].toLowerCase() : '';
568 var IMG = { jpg: 1, jpeg: 1, png: 1, gif: 1, webp: 1, avif: 1, svg: 1 };
569 if ( IMG[ ext ] ) {
570 return 'image/' + ( ext === 'jpg' ? 'jpeg' : ext === 'svg' ? 'svg+xml' : ext );
571 }
572 return '';
573 }
574
575 // ---------------------------------------------------------------
576 // Helpers — DOM scrape fallbacks, HTML/attribute escaping.
577 // ---------------------------------------------------------------
578
579 function scrapeUrl( el ) {
580 var img = el.querySelector( 'img' );
581 if ( img && img.src ) {
582 return img.src;
583 }
584 var a = el.querySelector( 'a[href]' );
585 return a ? a.getAttribute( 'href' ) : '';
586 }
587
588 function scrapeTitle( el ) {
589 var filename = el.querySelector( '.filename, .media-filename' );
590 if ( filename && filename.textContent ) {
591 return filename.textContent.trim();
592 }
593 var img = el.querySelector( 'img' );
594 return img ? ( img.alt || img.title || '' ) : '';
595 }
596
597 function escapeAttr( s ) {
598 return String( s )
599 .replace( /&/g, '&amp;' )
600 .replace( /"/g, '&quot;' )
601 .replace( /</g, '&lt;' )
602 .replace( />/g, '&gt;' );
603 }
604
605 function escapeHtml( s ) {
606 return String( s )
607 .replace( /&/g, '&amp;' )
608 .replace( /</g, '&lt;' )
609 .replace( />/g, '&gt;' );
610 }
611 } )();
612