Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/block-library/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Template Part: Show the "Default based on area" HTML element as selected instead of the `Select` placeholder ([#85172](https://github.com/WordPress/gutenberg/pull/85172)).
- Comments Next Page: Remove the link attributes filter after rendering, so it no longer stays attached to `next_comments_link_attributes` for the rest of the page load ([#85244](https://github.com/WordPress/gutenberg/pull/85244)).
- Image: Show the "Only one file can be used here." notice when several files are dropped on the placeholder where no Gallery can be inserted, instead of doing nothing; files dropped on the placeholder are accepted whichever window created them.

## 12.0.0 (2026-10-20)

Expand Down
92 changes: 58 additions & 34 deletions packages/block-library/src/image/edit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useResizeObserver } from '@wordpress/compose';
import { getProtocol, prependHTTPS } from '@wordpress/url';
import { store as uploadStore } from '@wordpress/upload-media';
import { useUploadMediaFromBlobURL } from '../utils/hooks';
import { isFile } from '../utils/is-file';
import Image from './image';
import { isValidFileType } from './utils';
import { useMaxWidthObserver } from './use-max-width-observer';
Expand Down Expand Up @@ -165,43 +166,66 @@ export function ImageEdit( {
}
}

function onSelectImagesList( images ) {
const win = containerRef.current?.ownerDocument.defaultView;
/**
* Turns several files dropped on the placeholder into a gallery (or into
* sibling images when the block already sits in a gallery).
*
* @param {File[]} files The dropped files.
*/
function onSelectImagesList( files ) {
const rootClientId = getBlockRootClientId( clientId );
const isInGallery = getBlockName( rootClientId ) === 'core/gallery';

// Defensive: only a list of files can become a gallery. `isFile`
// rather than `instanceof File` because the files may have been
// created in another window than the one rendering this block (a
// synthetic drop from the top window into the canvas iframe, say).
if ( ! files.every( isFile ) ) {
return;
}

if ( images.every( ( file ) => file instanceof win.File ) ) {
/** @type {File[]} */
const files = images;
const rootClientId = getBlockRootClientId( clientId );
// Without a gallery to put them in there is nothing this block can do
// with more than one file, so tell the user rather than leave the
// placeholder untouched.
if (
! isInGallery &&
! canInsertBlockType( 'core/gallery', rootClientId )
) {
createErrorNotice( __( 'Only one file can be used here.' ), {
type: 'snackbar',
} );
return;
}

if ( files.some( ( file ) => ! isValidFileType( file ) ) ) {
// Copied from the same notice in the gallery block.
createErrorNotice(
__(
'If uploading to a gallery all files need to be image formats'
),
{ id: 'gallery-upload-invalid-file', type: 'snackbar' }
);
}
if ( files.some( ( file ) => ! isValidFileType( file ) ) ) {
// Copied from the same notice in the gallery block.
createErrorNotice(
__(
'If uploading to a gallery all files need to be image formats'
),
{ id: 'gallery-upload-invalid-file', type: 'snackbar' }
);
}

const imageBlocks = files
.filter( ( file ) => isValidFileType( file ) )
.map( ( file ) =>
createBlock( 'core/image', {
blob: createBlobURL( file ),
} )
);

if ( getBlockName( rootClientId ) === 'core/gallery' ) {
replaceBlock( clientId, imageBlocks );
} else if ( canInsertBlockType( 'core/gallery', rootClientId ) ) {
const galleryBlock = createBlock(
'core/gallery',
{},
imageBlocks
);

replaceBlock( clientId, galleryBlock );
}
const imageBlocks = files
.filter( ( file ) => isValidFileType( file ) )
.map( ( file ) =>
createBlock( 'core/image', {
blob: createBlobURL( file ),
} )
);

if ( ! imageBlocks.length ) {
return;
}

if ( isInGallery ) {
replaceBlock( clientId, imageBlocks );
} else {
replaceBlock(
clientId,
createBlock( 'core/gallery', {}, imageBlocks )
);
}
}

Expand Down
8 changes: 1 addition & 7 deletions packages/block-library/src/playlist/edit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { createBlock } from '@wordpress/blocks';
import { createBlobURL } from '@wordpress/blob';
import { Caption } from '../utils/caption';
import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
import { isFile } from '../utils/is-file';
import { WaveformPlayer } from '../utils/waveform-player';
import { PlaylistContext } from './context';
import { getTrackAttributes } from './utils';
Expand All @@ -46,13 +47,6 @@ const WAVEFORM_STYLE_OPTIONS = [
{ label: _x( 'Seekbar', 'waveform style option' ), value: 'seekbar' },
];

function isFile( value ) {
return (
Object.prototype.toString.call( value ) === '[object File]' ||
( typeof File !== 'undefined' && value instanceof File )
);
}

function isAudioFile( file ) {
return file.type
? file.type.startsWith( 'audio/' )
Expand Down
17 changes: 17 additions & 0 deletions packages/block-library/src/utils/is-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Checks whether a value is a `File`, whichever browsing context created it.
*
* Each window has its own `File` constructor, so `instanceof File` is false
* for a file created in another window than the one running this code (the
* editor canvas is an iframe, and a synthetic drop can hand it files built in
* the top window). The `Object.prototype.toString` tag comes from
* `File.prototype[ Symbol.toStringTag ]`, which every realm's `File` and its
* subclasses share, so it is the realm-independent test.
*
* @param value The value to check.
*
* @return Whether the value is a `File`.
*/
export function isFile( value: unknown ): value is File {
return Object.prototype.toString.call( value ) === '[object File]';
}
30 changes: 30 additions & 0 deletions packages/block-library/src/utils/test/is-file.jsdom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { isFile } from '../is-file';

describe( 'isFile', () => {
it( 'accepts a File from the current realm', () => {
expect( isFile( new File( [ 'a' ], 'a.txt' ) ) ).toBe( true );
} );

it( 'accepts a File created by another realm', () => {
// A File from another window (the editor canvas iframe) is not an
// instance of this realm's `File`, but keeps the `File` string tag.
const foreignFile = Object.create( null, {
[ Symbol.toStringTag ]: { value: 'File' },
name: { value: 'a.png' },
type: { value: 'image/png' },
} );

expect( foreignFile instanceof File ).toBe( false );
expect( isFile( foreignFile ) ).toBe( true );
} );

it( 'rejects blobs and media library objects', () => {
expect( isFile( new Blob( [ 'a' ] ) ) ).toBe( false );
expect( isFile( { id: 1, url: 'https://example.com/a.png' } ) ).toBe(
false
);
expect( isFile( undefined ) ).toBe( false );
expect( isFile( null ) ).toBe( false );
} );
} );
53 changes: 53 additions & 0 deletions test/e2e/specs/editor/blocks/image.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,59 @@ test.describe( 'Image', () => {
} );
} );

test( 'should turn several files dropped into the media placeholder into a gallery, even files from another realm', async ( {
editor,
pageUtils,
} ) => {
await editor.insertBlock( { name: 'core/image' } );

const imageBlock = editor.canvas.locator(
'role=document[name="Block: Image"i]'
);
await expect( imageBlock ).toBeVisible();

// `dragFiles` creates the File objects in the top window rather than
// in the canvas iframe (a browser-dispatched drop creates them in the
// iframe): the block must not depend on the files being from its own
// realm.
const dragging = await pageUtils.dragFiles( [
'./assets/10x10_e2e_test_image_z9T8jK.png',
'./assets/10x10_e2e_test_image_green.png',
] );
await dragging.dragOver( imageBlock );
await dragging.drop();

const images = editor.canvas
.getByRole( 'document', { name: 'Block: Gallery' } )
.getByRole( 'img', {
name: 'This image has an empty alt attribute',
} );
await expect( images ).toHaveCount( 2 );

// Wait for both uploads to complete (includes client-side media processing time).
for ( const image of await images.all() ) {
await expect( image ).toHaveAttribute( 'src', /^https?:\/\//, {
timeout: 30_000,
} );
}

await expect.poll( editor.getBlocks ).toMatchObject( [
{
name: 'core/gallery',
innerBlocks: [
{
name: 'core/image',
attributes: { id: expect.any( Number ) },
},
{
name: 'core/image',
attributes: { id: expect.any( Number ) },
},
],
},
] );
} );

test( 'allows rotating an image using the media editor modal', async ( {
editor,
page,
Expand Down