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

502 lines 16.6 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 // Runtime guard — wp.media may not be present on every admin page.
39 // If it's not, there's nothing to enhance, so we bail silently.
40 if ( ! window.wp || ! window.wp.media || typeof window.wp.media.attachment !== 'function' ) {
41 // wp.media might be loaded lazily — register a short polling
42 // loop that gives up after 3 seconds. In practice it either
43 // lands within a few hundred ms or never arrives on this page.
44 var tries = 0;
45 var poll = setInterval( function () {
46 tries++;
47 if ( window.wp && window.wp.media && typeof window.wp.media.attachment === 'function' ) {
48 clearInterval( poll );
49 start();
50 } else if ( tries > 30 ) {
51 clearInterval( poll );
52 }
53 }, 100 );
54 return;
55 }
56 start();
57
58 // Flag set during our own attachment drags so the capture-phase
59 // blocker below can distinguish between "user is dragging a file
60 // from the OS" (which the WP uploader should handle) and "user is
61 // dragging a pre-existing attachment tile" (which the uploader
62 // must NOT intercept — otherwise it tries to re-upload it).
63 var dragInProgress = false;
64
65 // CSS selectors for the two enhancement paths:
66 // - GRID_SELECTOR matches the small tiles in the library grid and
67 // in the media modal's left-hand attachments browser.
68 // - DETAIL_SELECTOR matches the BIG preview in the right-hand
69 // details sidebar of the media modal, and the dedicated single-
70 // attachment edit page at `upload.php?item=ID`. WP renders the
71 // image inside a `.thumbnail` wrapper that has its own click
72 // handlers (swap-into-edit, set-as-featured, etc.) — the user
73 // reported that without explicit enhancement here the browser
74 // refuses to start a native drag from the big preview.
75 var GRID_SELECTOR = '.attachment';
76 var DETAIL_SELECTOR = '.attachment-details, .edit-attachment-frame';
77
78 function start() {
79 // Enhance whatever's already on the page.
80 document.querySelectorAll( GRID_SELECTOR ).forEach( enhance );
81 document.querySelectorAll( DETAIL_SELECTOR ).forEach( enhanceDetail );
82
83 // Watch for new tiles + detail panes — the media grid is a
84 // Backbone collection view that appends tiles on scroll, filter
85 // change, or modal open; the detail sidebar swaps its content
86 // node every time the user picks a different attachment.
87 // MutationObserver on the body catches all of them.
88 var observer = new MutationObserver( function ( mutations ) {
89 for ( var i = 0; i < mutations.length; i++ ) {
90 var added = mutations[ i ].addedNodes;
91 for ( var j = 0; j < added.length; j++ ) {
92 var node = added[ j ];
93 if ( node.nodeType !== 1 ) {
94 continue;
95 }
96 if ( node.matches && node.matches( GRID_SELECTOR ) ) {
97 enhance( node );
98 }
99 if ( node.matches && node.matches( DETAIL_SELECTOR ) ) {
100 enhanceDetail( node );
101 }
102 if ( node.querySelectorAll ) {
103 node.querySelectorAll( GRID_SELECTOR ).forEach( enhance );
104 node.querySelectorAll( DETAIL_SELECTOR ).forEach( enhanceDetail );
105 }
106 }
107 }
108 } );
109 observer.observe( document.body, { childList: true, subtree: true } );
110
111 installUploaderBlock();
112 }
113
114 /**
115 * Install capture-phase interceptors that stop drag events from
116 * reaching WordPress's Plupload dropzones while an attachment
117 * drag is in flight. Plupload doesn't check DataTransfer.types
118 * for the "Files" entry before claiming a drop, so without this
119 * it treats our attachment drag as a new-file upload attempt.
120 */
121 function installUploaderBlock() {
122 // Every uploader dropzone class WP core uses. `.drag-drop-area`
123 // is the text-and-icon panel inside the full-screen overlay;
124 // `.uploader-window` is the overlay itself; `.uploader-inline`
125 // and `.uploader-editor` cover the modal and classic-editor
126 // variants respectively.
127 var UPLOADER_SELECTOR = [
128 '.uploader-window',
129 '.uploader-inline',
130 '.uploader-editor',
131 '.drag-drop-area',
132 '.wp-uploader'
133 ].join( ',' );
134
135 var block = function ( e ) {
136 if ( ! dragInProgress ) {
137 return;
138 }
139 var t = e.target;
140 if ( ! t || typeof t.closest !== 'function' ) {
141 return;
142 }
143 if ( t.closest( UPLOADER_SELECTOR ) ) {
144 // Capture phase — fires before the uploader's own
145 // handler, so stopImmediatePropagation means the
146 // uploader never sees the event and therefore never
147 // calls preventDefault() to claim the drop.
148 e.stopImmediatePropagation();
149 if ( e.type === 'drop' || e.type === 'dragend' ) {
150 e.preventDefault();
151 }
152 }
153 };
154
155 document.addEventListener( 'dragenter', block, true );
156 document.addEventListener( 'dragover', block, true );
157 document.addEventListener( 'dragleave', block, true );
158 document.addEventListener( 'drop', block, true );
159
160 // Inject a tiny stylesheet that hides the uploader overlay
161 // while our drag is active. Belt-and-braces: even if the
162 // capture listener above misses an event, the UI won't flash
163 // the "Drop files here" overlay — nothing visible to signal
164 // to the user that a re-upload is about to happen.
165 var style = document.createElement( 'style' );
166 style.textContent =
167 'body.desktop-mode-dragging-attachment .uploader-window,' +
168 'body.desktop-mode-dragging-attachment .uploader-window-content,' +
169 'body.desktop-mode-dragging-attachment .uploader-editor-content,' +
170 'body.desktop-mode-dragging-attachment .wp-uploader {' +
171 ' display: none !important;' +
172 ' pointer-events: none !important;' +
173 '}';
174 document.head.appendChild( style );
175 }
176
177 /**
178 * Make a single .attachment tile draggable. Idempotent.
179 *
180 * @param {HTMLElement} el The .attachment element.
181 */
182 function enhance( el ) {
183 if ( el.dataset.desktopModeDraggable === '1' ) {
184 return;
185 }
186 el.dataset.desktopModeDraggable = '1';
187 el.setAttribute( 'draggable', 'true' );
188
189 el.addEventListener( 'dragstart', function ( e ) {
190 var id = parseInt( el.getAttribute( 'data-id' ) || el.dataset.id || '0', 10 );
191 if ( ! id ) {
192 return;
193 }
194 var model = wp.media.attachment( id );
195 var a = ( model && model.attributes ) ? model.attributes : {};
196 var url = resolveOriginalUrl( a, scrapeUrl( el ) );
197 var title = a.title || scrapeTitle( el );
198 if ( ! url ) {
199 e.preventDefault();
200 return;
201 }
202 populateDragTransfer( e, el, {
203 id: id,
204 url: url,
205 title: title,
206 alt: a.alt || title,
207 mime: a.mime || a.mimeType || '',
208 sizes: a.sizes || {},
209 } );
210 } );
211
212 el.addEventListener( 'dragend', onDragEnd );
213 }
214
215 /**
216 * Detail-view path — the BIG preview shown in the media modal's
217 * right-hand sidebar AND the dedicated single-attachment edit page
218 * at `upload.php?item=ID`. The grid-tile `enhance()` selector
219 * (`.attachment`) doesn't reach these containers, so without this
220 * companion the user can drag from the library grid but not from
221 * the detail view.
222 *
223 * The container itself is made `draggable=true`. Setting it on the
224 * wrapper rather than the inner `<img>` is intentional: the inner
225 * thumbnail has WP click handlers (swap-into-edit, set-as-featured)
226 * that can call `preventDefault` on `mousedown` and abort the
227 * browser's native image-drag before it gets a chance to start.
228 * Hoisting `draggable` to the parent gives us a clean handle and
229 * the `<img>` inside acts as the drag image.
230 *
231 * @param {HTMLElement} el The `.attachment-details` or
232 * `.edit-attachment-frame` container.
233 */
234 function enhanceDetail( el ) {
235 if ( el.dataset.desktopModeDraggable === '1' ) {
236 return;
237 }
238 el.dataset.desktopModeDraggable = '1';
239 el.setAttribute( 'draggable', 'true' );
240
241 el.addEventListener( 'dragstart', function ( e ) {
242 var id = resolveDetailId( el );
243 var model = id ? wp.media.attachment( id ) : null;
244 var a = ( model && model.attributes ) ? model.attributes : {};
245
246 var img = el.querySelector(
247 '.thumbnail img, .attachment-media-view img, .details-image, img'
248 );
249 var fallbackUrl = img && ( img.currentSrc || img.src ) || '';
250 var url = resolveOriginalUrl( a, fallbackUrl );
251 if ( ! url ) {
252 e.preventDefault();
253 return;
254 }
255 var title = a.title
256 || scrapeDetailTitle( el )
257 || ( img && ( img.alt || img.title ) )
258 || '';
259
260 populateDragTransfer( e, el, {
261 id: id || 0,
262 url: url,
263 title: title,
264 alt: a.alt || title,
265 mime: a.mime || a.mimeType || guessMimeFromUrl( url ),
266 sizes: a.sizes || {},
267 } );
268 } );
269
270 el.addEventListener( 'dragend', onDragEnd );
271 }
272
273 /**
274 * Shared tail of every dragstart handler: arm the uploader-block
275 * interceptor, populate DataTransfer with text/uri-list + text/html
276 * + the WP-aware custom MIME, and postMessage the payload up to the
277 * parent shell so the cross-iframe bridge has it.
278 *
279 * @param {DragEvent} e
280 * @param {HTMLElement} sourceEl The element being dragged (for
281 * the drag image fallback).
282 * @param {{id:number,url:string,title:string,alt:string,
283 * mime:string,sizes:object,thumbnailUrl?:string}} record
284 */
285 function populateDragTransfer( e, sourceEl, record ) {
286 dragInProgress = true;
287 document.body.classList.add( 'desktop-mode-dragging-attachment' );
288
289 var url = record.url;
290 var title = record.title;
291 var alt = record.alt || title;
292 var mime = record.mime || '';
293 var thumbnailUrl = record.thumbnailUrl
294 || ( record.sizes && record.sizes.thumbnail && record.sizes.thumbnail.url )
295 || url;
296
297 try {
298 e.dataTransfer.setData( 'text/plain', url );
299 e.dataTransfer.setData( 'text/uri-list', url );
300
301 if ( mime.indexOf( 'image/' ) === 0 ) {
302 e.dataTransfer.setData(
303 'text/html',
304 '<img src="' + escapeAttr( url ) + '" alt="' + escapeAttr( alt ) + '" />'
305 );
306 } else {
307 e.dataTransfer.setData(
308 'text/html',
309 '<a href="' + escapeAttr( url ) + '">' + escapeHtml( title || url ) + '</a>'
310 );
311 }
312
313 e.dataTransfer.setData(
314 'application/x-wp-media-attachment',
315 JSON.stringify( {
316 id: record.id,
317 url: url,
318 title: title,
319 alt: alt,
320 mime: mime,
321 sizes: record.sizes || {},
322 } )
323 );
324
325 e.dataTransfer.effectAllowed = 'copy';
326
327 var thumb = sourceEl.querySelector( 'img' );
328 if ( thumb && thumb.complete && thumb.naturalWidth > 0 ) {
329 e.dataTransfer.setDragImage( thumb, thumb.width / 2, thumb.height / 2 );
330 }
331 } catch ( err ) {
332 // setData can throw in older browsers or under hostile CSP.
333 }
334
335 try {
336 if ( window.parent && window.parent !== window ) {
337 window.parent.postMessage( {
338 type: 'desktop-mode-drag-start',
339 payload: {
340 id: record.id,
341 url: url,
342 title: title,
343 alt: alt,
344 mime: mime,
345 sizes: record.sizes || {},
346 thumbnailUrl: thumbnailUrl,
347 },
348 }, window.location.origin );
349 }
350 } catch ( postErr ) {
351 // Cross-origin parent or sandboxed frame — the drag still
352 // works via native DataTransfer.
353 }
354 }
355
356 function onDragEnd() {
357 dragInProgress = false;
358 document.body.classList.remove( 'desktop-mode-dragging-attachment' );
359 try {
360 if ( window.parent && window.parent !== window ) {
361 window.parent.postMessage(
362 { type: 'desktop-mode-drag-end' },
363 window.location.origin
364 );
365 }
366 } catch ( err ) { /* swallow */ }
367 }
368
369 /**
370 * Resolve the attachment id for a detail-view container. WP exposes
371 * it in several places depending on the surface:
372 *
373 * - Modal sidebar: `<div class="attachment-details" data-id="N">`
374 * - Single-attachment page: `?item=N` in the URL, or a hidden
375 * `#post_ID` input emitted by the post editor.
376 *
377 * Returns 0 when no id can be found — `populateDragTransfer`
378 * tolerates id=0 and still ships a working drag using the
379 * scraped URL.
380 */
381 function resolveDetailId( el ) {
382 var raw = el.getAttribute( 'data-id' )
383 || ( el.dataset && el.dataset.id )
384 || '';
385 var n = parseInt( raw, 10 );
386 if ( n ) return n;
387
388 try {
389 var q = new URLSearchParams( window.location.search );
390 n = parseInt( q.get( 'item' ) || q.get( 'post' ) || '0', 10 );
391 if ( n ) return n;
392 } catch ( err ) { /* old browser */ }
393
394 var hidden = document.getElementById( 'post_ID' );
395 if ( hidden && hidden.value ) {
396 n = parseInt( hidden.value, 10 );
397 if ( n ) return n;
398 }
399 return 0;
400 }
401
402 function scrapeDetailTitle( el ) {
403 var input = el.querySelector( '[data-setting="title"] input, #title' );
404 if ( input && input.value ) return input.value;
405 var filename = el.querySelector( '.filename, .filename .file' );
406 return filename ? filename.textContent.trim() : '';
407 }
408
409 /**
410 * Resolve the most-original URL for an attachment, in order:
411 *
412 * 1. `originalImageURL` from the model — WP 5.3+ exposes this
413 * when the uploaded image was big enough to trigger the
414 * `-scaled` derivative. It points at the un-scaled original.
415 * 2. The model's `url`, with WP-generated suffixes stripped:
416 * `-WxH` size variants AND the `-scaled` marker (both, in
417 * either order, anchored to the extension).
418 * 3. The DOM-scraped fallback (the thumbnail src), with the
419 * same suffix normalisation.
420 *
421 * Returns '' when nothing is available.
422 *
423 * @param {object} attrs The attachment model's `attributes`.
424 * @param {string} fallback URL scraped from the DOM (thumbnail).
425 * @return {string}
426 */
427 function resolveOriginalUrl( attrs, fallback ) {
428 if ( attrs && attrs.originalImageURL ) {
429 return attrs.originalImageURL;
430 }
431 var candidate = ( attrs && attrs.url ) || fallback || '';
432 return stripSizeSuffix( candidate );
433 }
434
435 /**
436 * Strip `-WxH` (e.g. `-300x167`) and `-scaled` suffixes immediately
437 * before the file extension, preserving any query string / fragment.
438 *
439 * foo-300x167.jpg → foo.jpg
440 * foo-scaled.jpg → foo.jpg
441 * foo-300x167-scaled.jpg → foo.jpg
442 * foo.jpg?ver=1 → foo.jpg?ver=1
443 * foo.jpg → foo.jpg
444 */
445 function stripSizeSuffix( url ) {
446 if ( ! url ) return url;
447 return url.replace(
448 /(-\d+x\d+)?(-scaled)?(\.[a-z0-9]+)(\?[^#]*)?(#.*)?$/i,
449 function ( _m, _wh, _sc, ext, query, hash ) {
450 return ext + ( query || '' ) + ( hash || '' );
451 }
452 );
453 }
454
455 function guessMimeFromUrl( url ) {
456 var m = /\.([a-z0-9]+)(?:\?|#|$)/i.exec( url || '' );
457 var ext = m ? m[ 1 ].toLowerCase() : '';
458 var IMG = { jpg: 1, jpeg: 1, png: 1, gif: 1, webp: 1, avif: 1, svg: 1 };
459 if ( IMG[ ext ] ) {
460 return 'image/' + ( ext === 'jpg' ? 'jpeg' : ext === 'svg' ? 'svg+xml' : ext );
461 }
462 return '';
463 }
464
465 // ---------------------------------------------------------------
466 // Helpers — DOM scrape fallbacks, HTML/attribute escaping.
467 // ---------------------------------------------------------------
468
469 function scrapeUrl( el ) {
470 var img = el.querySelector( 'img' );
471 if ( img && img.src ) {
472 return img.src;
473 }
474 var a = el.querySelector( 'a[href]' );
475 return a ? a.getAttribute( 'href' ) : '';
476 }
477
478 function scrapeTitle( el ) {
479 var filename = el.querySelector( '.filename, .media-filename' );
480 if ( filename && filename.textContent ) {
481 return filename.textContent.trim();
482 }
483 var img = el.querySelector( 'img' );
484 return img ? ( img.alt || img.title || '' ) : '';
485 }
486
487 function escapeAttr( s ) {
488 return String( s )
489 .replace( /&/g, '&amp;' )
490 .replace( /"/g, '&quot;' )
491 .replace( /</g, '&lt;' )
492 .replace( />/g, '&gt;' );
493 }
494
495 function escapeHtml( s ) {
496 return String( s )
497 .replace( /&/g, '&amp;' )
498 .replace( /</g, '&lt;' )
499 .replace( />/g, '&gt;' );
500 }
501 } )();
502