diff --git a/Gruntfile.js b/Gruntfile.js index 139cb6fb5b286..2570e99d41351 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -526,6 +526,9 @@ module.exports = function(grunt) { [ WORKING_DIR + 'wp-admin/js/language-chooser.js' ]: [ './src/js/_enqueues/lib/language-chooser.js' ], [ WORKING_DIR + 'wp-admin/js/link.js' ]: [ './src/js/_enqueues/admin/link.js' ], [ WORKING_DIR + 'wp-admin/js/media-gallery.js' ]: [ './src/js/_enqueues/deprecated/media-gallery.js' ], + [ WORKING_DIR + 'wp-admin/js/media-library-upload.js' ]: [ './src/js/_enqueues/admin/media-library-upload.js' ], + [ WORKING_DIR + 'wp-admin/js/media-new-upload.js' ]: [ './src/js/_enqueues/admin/media-new-upload.js' ], + [ WORKING_DIR + 'wp-admin/js/media-upload-pipeline.js' ]: [ './src/js/_enqueues/admin/media-upload-pipeline.js' ], [ WORKING_DIR + 'wp-admin/js/media-upload.js' ]: [ './src/js/_enqueues/admin/media-upload.js' ], [ WORKING_DIR + 'wp-admin/js/media.js' ]: [ './src/js/_enqueues/admin/media.js' ], [ WORKING_DIR + 'wp-admin/js/nav-menu.js' ]: [ './src/js/_enqueues/lib/nav-menu.js' ], @@ -1280,6 +1283,9 @@ module.exports = function(grunt) { 'src/wp-admin/js/language-chooser.js': 'src/js/_enqueues/lib/language-chooser.js', 'src/wp-admin/js/link.js': 'src/js/_enqueues/admin/link.js', 'src/wp-admin/js/media-gallery.js': 'src/js/_enqueues/deprecated/media-gallery.js', + 'src/wp-admin/js/media-library-upload.js': 'src/js/_enqueues/admin/media-library-upload.js', + 'src/wp-admin/js/media-new-upload.js': 'src/js/_enqueues/admin/media-new-upload.js', + 'src/wp-admin/js/media-upload-pipeline.js': 'src/js/_enqueues/admin/media-upload-pipeline.js', 'src/wp-admin/js/media-upload.js': 'src/js/_enqueues/admin/media-upload.js', 'src/wp-admin/js/media.js': 'src/js/_enqueues/admin/media.js', 'src/wp-admin/js/nav-menu.js': 'src/js/_enqueues/lib/nav-menu.js', diff --git a/src/js/_enqueues/admin/media-library-upload.js b/src/js/_enqueues/admin/media-library-upload.js new file mode 100644 index 0000000000000..286e1184708bd --- /dev/null +++ b/src/js/_enqueues/admin/media-library-upload.js @@ -0,0 +1,303 @@ +/** + * Routes Media Library grid uploads through the client-side media pipeline. + * + * On wp-admin/upload.php (grid mode) WordPress uploads via wp.Uploader / + * plupload to async-upload.php. When the browser is cross-origin isolated + * and supports the client-side pipeline, this script intercepts the + * uploader's FilesAdded handler and hands files to wp.mediaUploadPipeline + * (media-upload-pipeline.js) instead: the original image is uploaded via + * the REST API and thumbnails are generated in the browser (wasm-vips), + * then sideloaded and finalized. + * + * The grid's own UI is reused: the same placeholder tiles, progress bars, + * "Uploading n/m" status, and error sidebar that wp-plupload.js drives. + * + * When client-side support is unavailable the script cleanly no-ops and + * the classic plupload flow is left untouched. + * + * @output wp-admin/js/media-library-upload.js + */ + +/* global plupload */ + +/** + * The parts of a wp.Uploader instance this script relies on. + * + * @typedef {Object} WPUploader + * @property {plupload.Uploader} uploader The plupload uploader it wraps. + * @property {( model: WPAttachment ) => void} added Runs when a file is queued. + * @property {( model: WPAttachment ) => void} success Runs when an upload finished. + * @property {( message: string, data: Object, file: { name: string } ) => void} error Runs when an upload failed. + */ + +/** + * The parts of a wp.media.model.Attachment (a Backbone model) this script + * relies on. + * + * @typedef {Object} WPAttachment + * @property {( key: string ) => unknown} get Reads a model attribute. + * @property {( attributes: Object, options?: Object ) => void} set Sets model attributes. + * @property {( key: string, options?: Object ) => void} unset Removes a model attribute. + * @property {() => JQuery.jqXHR} fetch Refetches the attachment from the REST API. + * @property {() => void} destroy Removes the model and its tile. + */ + +/** + * The placeholder attributes wp-plupload.js builds for an uploading tile. + * + * @typedef {Object} PlaceholderAttributes + * @property {plupload.File} file The file being uploaded. + * @property {boolean} uploading Always true while the upload runs. + * @property {Date} date When the upload started. + * @property {string} filename The file name. + * @property {number} menuOrder Menu order of the attachment. + * @property {number} uploadedTo The post the upload is attached to. + * @property {number} loaded Bytes uploaded so far. + * @property {number} size Size of the file in bytes. + * @property {number} percent Progress percentage. + * @property {string} [type] Mime type guessed from the file name. + * @property {string} [subtype] Mime subtype guessed from the file name. + */ + +/* + * PipelineAttachment and UploadError are declared by media-upload-pipeline.js, + * which this script depends on. + */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaLibraryUpload ) { + return; + } + + const pipeline = window.wp && wp.mediaUploadPipeline; + + // Bail unless the browser actually supports client-side processing. This + // is the clean no-op: when the isolation headers did not land, classic + // plupload keeps handling uploads. + if ( + ! pipeline || + typeof plupload === 'undefined' || + ! wp.Uploader || + ! wp.media || + ! pipeline.configure() + ) { + return; + } + + window.__wpMediaLibraryUpload = true; + + /** + * Resets the upload queue once every attachment has finished uploading. + * + * Parity with wp-plupload.js so browse mode flips back when done. + */ + function maybeResetQueue() { + const complete = wp.Uploader.queue.all( function ( + /** @type {WPAttachment} */ attachment + ) { + return ! attachment.get( 'uploading' ); + } ); + + if ( complete ) { + wp.Uploader.queue.reset(); + } + } + + /** + * Handles a completed upload by syncing the grid tile with the server data. + * + * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file. + * @param {WPAttachment} model The placeholder Attachment model. + * @param {PipelineAttachment} attachment The finalized attachment from the pipeline. + */ + function handleSuccess( wpUploader, model, attachment ) { + model.set( { id: attachment.id }, { silent: true } ); + + // Register the model in Attachments.all (parity with wp-plupload.js). + wp.media.model.Attachment.get( attachment.id, model ); + + model + .fetch() + .done( function () { + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( + key + ) { + model.unset( key, { silent: true } ); + } ); + model.set( { uploading: false } ); + } ) + .fail( function () { + // Fetch failed, but the upload succeeded: clear the uploading + // state with what the pipeline gave us so no tile is stuck. + [ 'file', 'loaded', 'size', 'percent' ].forEach( function ( + key + ) { + model.unset( key, { silent: true } ); + } ); + model.set( attachment ); + model.set( { uploading: false } ); + } ) + .always( function () { + maybeResetQueue(); + + // Parity with wp-plupload.js, which exposes this callback so + // other code can react to a finished upload. + wpUploader.success( model ); + } ); + } + + /** + * Handles an upload error by removing the tile and surfacing the message. + * + * The error goes into wp.Uploader.errors exactly like a classic upload + * error, so the grid's error sidebar renders it, announces it, and + * moves focus to its Dismiss button. + * + * @param {WPUploader} wpUploader The wp.Uploader instance that queued the file. + * @param {WPAttachment} model The placeholder Attachment model. + * @param {UploadError} error The upload error. + * @param {File} nativeFile The original file (for the error label). + */ + function handleError( wpUploader, model, error, nativeFile ) { + const message = pipeline.getErrorText( error, nativeFile.name ); + const file = { name: nativeFile.name }; + + model.destroy(); + + wp.Uploader.errors.unshift( { + message: message, + data: {}, + file: file, + } ); + + maybeResetQueue(); + + // Parity with wp-plupload.js, which exposes this callback so other + // code can react to a failed upload. + wpUploader.error( message, {}, file ); + } + + /** + * Intercepts files added to a plupload uploader. + * + * Returns undefined (not false) when the pipeline cannot take the batch + * so the built-in handler runs and uploads server-side - a degradation, + * never data loss. Otherwise builds the same placeholder tiles as + * wp-plupload, routes each file through the pipeline, and returns false + * to suppress the built-in handler. + * + * @param {WPUploader} wpUploader The wp.Uploader instance. + * @param {plupload.Uploader} up The plupload uploader instance. + * @param {plupload.File[]} files Files added to the queue. + * @return {boolean|undefined} False to suppress the built-in handler. + */ + function handleFilesAdded( wpUploader, up, files ) { + if ( ! pipeline.isReady() || ! pipeline.canHandleBatch( files ) ) { + return; + } + + // The classic flow posts plupload's multipart params to + // async-upload.php; forward them so plugins reading $_POST see the + // same fields, with `post_id` spelled `post` for the REST API. + const params = ( up.settings && up.settings.multipart_params ) || {}; + const additionalData = pipeline.additionalDataFromParams( + params, + parseInt( params.post_id, 10 ) || 0 + ); + + files.forEach( function ( file ) { + // Ignore failed uploads. + if ( plupload.FAILED === file.status ) { + return; + } + + // Build the same placeholder attributes as wp-plupload.js so the + // grid's progress tiles and "Uploading n/m" status work unchanged. + /** @type {PlaceholderAttributes} */ + const attributes = { + file: file, + uploading: true, + date: new Date(), + filename: file.name, + menuOrder: 0, + uploadedTo: wp.media.model.settings.post.id, + loaded: file.loaded, + size: file.size, + percent: file.percent, + }; + + /* + * Early mime type scanning for images, as wp-plupload.js does, + * extended with the formats the client-side pipeline accepts. + */ + const image = /(?:jpe?g|png|gif|webp|avif|heic|heif)$/i.exec( + file.name + ); + if ( image ) { + attributes.type = 'image'; + // `jpg` is not a valid subtype, so map it to `jpeg`. + attributes.subtype = + 'jpg' === image[ 0 ].toLowerCase() + ? 'jpeg' + : image[ 0 ].toLowerCase(); + } + + const model = wp.media.model.Attachment.create( attributes ); + wp.Uploader.queue.add( model ); + wpUploader.added( model ); + + // canHandleBatch() already established that every file has one. + const nativeFile = /** @type {File} */ ( file.getNative() ); + + // Remove the file from plupload so it is not uploaded twice. + up.removeFile( file ); + + pipeline.queueFile( nativeFile, additionalData, { + onSuccess: function ( + /** @type {PipelineAttachment} */ attachment + ) { + handleSuccess( wpUploader, model, attachment ); + }, + onError: function ( /** @type {UploadError} */ error ) { + handleError( wpUploader, model, error, nativeFile ); + }, + onProgress: function ( /** @type {number} */ percent ) { + model.set( { percent: percent } ); + }, + } ); + } ); + + up.refresh(); + + return false; + } + + // Wrap wp.Uploader.prototype.init (an empty stub called once per instance + // after plupload is initialized) to bind a higher-priority FilesAdded + // handler on every uploader instance, including the Media Library grid's. + const originalInit = wp.Uploader.prototype.init; + wp.Uploader.prototype.init = function () { + originalInit.apply( this, arguments ); + + const wpUploader = /** @type {WPUploader} */ ( this ); + const up = /** @type {plupload.Uploader|undefined} */ ( this.uploader ); + + if ( ! up || up.__wpMediaLibraryUploadBound ) { + return; + } + up.__wpMediaLibraryUploadBound = true; + + // plupload sorts handlers by priority (descending) and a `false` + // return breaks the chain, so priority 100 runs before and suppresses + // the built-in FilesAdded handler. + up.bind( + 'FilesAdded', + function ( uploader, files ) { + return handleFilesAdded( wpUploader, uploader, files ); + }, + this, + 100 + ); + }; +} )(); diff --git a/src/js/_enqueues/admin/media-new-upload.js b/src/js/_enqueues/admin/media-new-upload.js new file mode 100644 index 0000000000000..60643dfc7a49b --- /dev/null +++ b/src/js/_enqueues/admin/media-new-upload.js @@ -0,0 +1,284 @@ +/** + * Routes "Add New Media File" uploads through the client-side media pipeline. + * + * On wp-admin/media-new.php WordPress uploads via a raw plupload.Uploader + * (created by plupload-handlers) posting to async-upload.php. When the + * browser is cross-origin isolated and supports the client-side pipeline, + * this script intercepts the uploader's FilesAdded handler and hands files + * to wp.mediaUploadPipeline (media-upload-pipeline.js) instead: the + * original image is uploaded via the REST API and thumbnails are generated + * in the browser (wasm-vips), then sideloaded and finalized. + * + * The screen's existing UI helpers from plupload-handlers are reused: + * fileQueued() builds the progress item, uploadSuccess() renders the + * finished attachment row (via the async-upload.php markup endpoint), and + * uploadComplete() runs when the queue drains. Failed uploads render the + * same notice async-upload.php returns for a server-side failure, so the + * screen looks and behaves unchanged. + * + * When client-side support is unavailable the script cleanly no-ops and + * the classic plupload flow is left untouched. + * + * @output wp-admin/js/media-new-upload.js + */ + +/* global plupload, pluploadL10n, uploader, fileQueued, uploadStart, uploadSuccess, uploadComplete */ + +( function () { + // Guard against double execution (e.g. duplicate enqueues). + if ( window.__wpMediaNewUpload ) { + return; + } + + const pipeline = window.wp && wp.mediaUploadPipeline; + + // Bail unless the browser actually supports client-side processing. This + // is the clean no-op: when the isolation headers did not land, classic + // plupload keeps handling uploads. + if ( + ! pipeline || + typeof plupload === 'undefined' || + typeof jQuery === 'undefined' || + ! wp.a11y || + ! pipeline.configure() + ) { + return; + } + + window.__wpMediaNewUpload = true; + + const __ = wp.i18n.__; + const sprintf = wp.i18n.sprintf; + + // Number of pipeline uploads currently in flight, used to fire + // uploadComplete() when the queue drains. + let inFlightCount = 0; + + /** + * Marks one pipeline upload as finished, firing uploadComplete() when + * the queue drains. + * + * The built-in UploadComplete binding never fires for pipeline uploads + * because every file is removed from plupload before its queue starts. + */ + function finishUpload() { + inFlightCount--; + if ( inFlightCount === 0 ) { + uploadComplete(); + } + } + + /** + * Returns the post the screen attaches uploads to, or 0. + * + * media-new.php validates `post_id` (the post must exist and be + * editable by the user) before printing it into the form, whereas the + * raw request value is what plupload carries in its multipart params. + * async-upload.php re-validates that raw value and silently uploads + * unattached; the REST API rejects it instead, so read the validated one. + * + * @return {number} The parent post ID, or 0. + */ + function getParentPostId() { + const input = /** @type {HTMLInputElement|null} */ ( + document.getElementById( 'post_id' ) + ); + const postId = input ? parseInt( input.value, 10 ) : 0; + return postId > 0 ? postId : 0; + } + + /** + * Escapes text for insertion into HTML. + * + * @param {string} text The text to escape. + * @return {string} The escaped text. + */ + function escapeHtml( text ) { + return jQuery( '
' ).text( text ).html(); + } + + /** + * Renders a failed upload the way async-upload.php does for a + * server-side failure: an error notice with a real Dismiss button + * described by the notice, a screen reader announcement, and focus + * returned to the browse button once dismissed. + * + * @param {plupload.File} file The plupload file that failed. + * @param {string} message The reason the upload failed. + */ + function renderError( file, message ) { + const item = jQuery( '#media-item-' + file.id ); + const buttonId = 'dismiss-' + file.id; + const descriptionId = 'error-description-' + file.id; + + const button = jQuery( '