PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.6
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 1.1.6, at assets/js/media-library-enhanced.js

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