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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { GeneralPurposeImage } from '@automattic/jetpack-ai-client';
import { getRedirectUrl, ThemeProvider } from '@automattic/jetpack-components';
import { siteHasFeature } from '@automattic/jetpack-script-data';
import { useAnalytics } from '@automattic/jetpack-shared-extension-utils';
import { MediaUpload } from '@wordpress/block-editor';
import { BaseControl, Button } from '@wordpress/components';
Expand All @@ -18,7 +19,9 @@ import useMediaDetails from '../../hooks/use-media-details';
import { SELECTABLE_MEDIA_TYPES } from '../../hooks/use-media-restrictions/restrictions';
import { usePostMeta } from '../../hooks/use-post-meta';
import useSigPreview from '../../hooks/use-sig-preview';
import { features } from '../../utils';
import CustomMediaToggle from './custom-media-toggle';
import MediaFocalPoint from './media-focal-point';
import MediaPreview from './media-preview';
import MediaSourceMenu from './media-source-menu';
import styles from './styles.module.scss';
Expand Down Expand Up @@ -188,6 +191,18 @@ export default function MediaSectionV2( {
// Preview will render the SIG image whenever SIG is the explicit source or the OG fallback in Default mode.
const isPreviewingSig = currentSource === 'sig' || ( ! currentSource && sigEnabled );

/*
* The focal point can only be set against a real image attachment: hidden for SIG
* (attachment id 0) and video. It is per image, not per connection, so it shows in
* controlled/per-network mode too — MediaFocalPoint reads and writes the post-level
* image_focal_points map directly via use-post-meta, never through onMediaChange.
* Gated on the wpcom-controlled rollout flag until the cropping consumers ship.
*/
const showFocalPointPicker =
siteHasFeature( features.IMAGE_FOCAL_POINT ) &&
previewData?.type === 'image' &&
previewData.id > 0;

// Handle media source selection from dropdown
const handleSourceSelect = useCallback(
( source: MediaSourceType ) => {
Expand Down Expand Up @@ -428,7 +443,11 @@ export default function MediaSectionV2( {
{ /* Show preview + dropdown when there's media */ }
{ previewData && (
<>
<MediaPreview media={ previewData } isLoading={ isPreviewingSig && sigIsLoading } />
{ showFocalPointPicker ? (
<MediaFocalPoint url={ previewData.url } attachmentId={ previewData.id } />
) : (
<MediaPreview media={ previewData } isLoading={ isPreviewingSig && sigIsLoading } />
) }
<div className={ styles.actions }>
<MediaSourceMenu
currentSource={ currentSource }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* MediaFocalPoint component
* Lets the user mark the most important part of the social image
*/

import { FocalPointPicker } from '@wordpress/components';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { usePostMeta } from '../../hooks/use-post-meta';
import { MediaFocalPointProps } from './types';

const DEFAULT_FOCAL_POINT = { x: 0.5, y: 0.5 };

/**
* MediaFocalPoint component
*
* @param {MediaFocalPointProps} props - Component props
* @return MediaFocalPoint component
*/
export default function MediaFocalPoint( { url, attachmentId }: MediaFocalPointProps ) {
const { imageFocalPoints, updateImageFocalPoint } = usePostMeta();

// One point per image: look up the entry for this attachment.
const value = imageFocalPoints?.[ attachmentId ] ?? DEFAULT_FOCAL_POINT;

// Commit only on drag end (onChange); the picker tracks the marker during drag itself.
const onChange = useCallback(
( focalPoint: { x: number; y: number } ) => {
updateImageFocalPoint( attachmentId, {
x: Math.round( focalPoint.x * 100 ) / 100,
y: Math.round( focalPoint.y * 100 ) / 100,
} );
},
[ attachmentId, updateImageFocalPoint ]
);

return (
<FocalPointPicker
__nextHasNoMarginBottom
label={ __( 'Focal point', 'jetpack-publicize-pkg' ) }
help={ __(
'Drag the point to the most important part of the image.',
'jetpack-publicize-pkg'
) }
url={ url }
value={ value }
onChange={ onChange }
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MediaSectionV2 from '..';
import useFeaturedImage from '../../../hooks/use-featured-image';
Expand All @@ -12,6 +12,15 @@ const mockUpdateJetpackSocialOptions = jest.fn();
const mockRecordEvent = jest.fn();
const mockOpenUnifiedModal = jest.fn();
const mockApplyFilters = jest.fn();
const mockSiteHasFeature = jest.fn< boolean, [ string ] >( () => true );

jest.mock( '@automattic/jetpack-script-data', () => {
const actual = jest.requireActual( '@automattic/jetpack-script-data' );
return {
...actual,
siteHasFeature: ( feature: string ) => mockSiteHasFeature( feature ),
};
} );

// Mock the social store to prevent importing @wordpress/editor
jest.mock( '../../../social-store', () => ( {
Expand Down Expand Up @@ -85,6 +94,7 @@ jest.mock( '../../../utils', () => ( {
jetpack: { version: '15.5' },
},
} ) ),
features: jest.requireActual( '../../../utils/constants' ).features,
} ) );

jest.mock(
Expand Down Expand Up @@ -399,6 +409,155 @@ describe( 'MediaSectionV2', () => {
} );
} );

describe( 'Focal point picker', () => {
const attachedImageState = {
attachedMedia: [ { id: 789, url: 'https://example.com/attached.jpg', type: 'image/jpeg' } ],
imageGeneratorSettings: { enabled: false },
mediaSource: 'media-library',
updateJetpackSocialOptions: mockUpdateJetpackSocialOptions,
};

afterEach( () => {
mockSiteHasFeature.mockReturnValue( true );
( usePostMeta as jest.Mock ).mockReturnValue( {
attachedMedia: [],
imageGeneratorSettings: { enabled: false },
mediaSource: undefined,
updateJetpackSocialOptions: mockUpdateJetpackSocialOptions,
} );
( useMediaDetails as jest.Mock ).mockReturnValue( [
{
mediaData: { sourceUrl: 'https://example.com/featured.jpg' },
metaData: { mime: 'image/jpeg' },
},
false,
] );
} );

it( 'should hide the picker when the feature flag is off', () => {
mockSiteHasFeature.mockReturnValue( false );
( useMediaDetails as jest.Mock ).mockReturnValue( [
{
mediaData: { sourceUrl: 'https://example.com/featured.jpg' },
metaData: { mime: 'image/jpeg' },
},
false,
] );

render( <MediaSectionV2 /> );

expect( mockSiteHasFeature ).toHaveBeenCalledWith( 'social-image-focal-point' );
expect( screen.queryByText( 'Focal point' ) ).not.toBeInTheDocument();
// The plain preview is shown instead.
expect( screen.getByRole( 'img' ) ).toHaveAttribute(
'src',
'https://example.com/featured.jpg'
);
} );

it( 'should show the picker for an attached image', () => {
( usePostMeta as jest.Mock ).mockReturnValue( attachedImageState );
( useMediaDetails as jest.Mock ).mockReturnValue( [
{
mediaData: { sourceUrl: 'https://example.com/attached.jpg' },
metaData: { mime: 'image/jpeg' },
},
false,
] );

render( <MediaSectionV2 /> );

expect( screen.getByText( 'Focal point' ) ).toBeInTheDocument();
} );

it( 'should show the picker for the featured image', () => {
render( <MediaSectionV2 /> );

expect( screen.getByText( 'Focal point' ) ).toBeInTheDocument();
} );

it( 'should hide the picker when SIG is the media source', () => {
( usePostMeta as jest.Mock ).mockReturnValue( {
attachedMedia: [],
imageGeneratorSettings: { enabled: true },
mediaSource: 'sig',
updateJetpackSocialOptions: mockUpdateJetpackSocialOptions,
} );

render( <MediaSectionV2 /> );

expect( screen.queryByText( 'Focal point' ) ).not.toBeInTheDocument();
// The plain preview is shown instead.
expect( screen.getByRole( 'img' ) ).toHaveAttribute(
'src',
'https://example.com/sig-preview.jpg'
);
} );

it( 'should hide the picker for videos', () => {
( usePostMeta as jest.Mock ).mockReturnValue( {
...attachedImageState,
attachedMedia: [ { id: 789, url: 'https://example.com/video.mp4', type: 'video/mp4' } ],
} );
( useMediaDetails as jest.Mock ).mockReturnValue( [
{
mediaData: { sourceUrl: 'https://example.com/video.mp4' },
metaData: { mime: 'video/mp4' },
},
false,
] );

render( <MediaSectionV2 /> );

expect( screen.queryByText( 'Focal point' ) ).not.toBeInTheDocument();
} );

it( 'should show the picker in per-network/controlled mode', () => {
render(
<MediaSectionV2
attachmentToggleMode="hidden"
mediaSource="media-library"
attachedMedia={ [
{ id: 789, url: 'https://example.com/attached.jpg', type: 'image/jpeg' },
] }
onMediaChange={ mockUpdateJetpackSocialOptions }
/>
);

// The point is per image, not per connection, so the picker renders in
// controlled mode too and writes the post-level map directly.
expect( screen.getByText( 'Focal point' ) ).toBeInTheDocument();
} );

it( 'should not touch the focal point map when the media changes', async () => {
const user = userEvent.setup();

render( <MediaSectionV2 /> );

await user.click( screen.getByRole( 'button', { name: 'Select' } ) );
await user.click( screen.getByRole( 'menuitemradio', { name: 'From Media Library' } ) );

await waitFor( () => expect( mockUpdateJetpackSocialOptions ).toHaveBeenCalled() );

// Each image keeps its own entry — switching media must not clear the map.
const updates = mockUpdateJetpackSocialOptions.mock.calls[ 0 ][ 0 ];
expect( updates.media_source ).toBe( 'media-library' );
expect( 'image_focal_points' in updates ).toBe( false );
} );

it( 'should not touch the focal point map when toggling share as attachment', async () => {
const user = userEvent.setup();

render( <MediaSectionV2 /> );

await user.click( screen.getByRole( 'checkbox', { name: 'Share as attachment' } ) );

const updates = mockUpdateJetpackSocialOptions.mock.calls[ 0 ][ 0 ];
expect( updates.media_source ).toBe( 'featured-image' );
expect( 'image_focal_points' in updates ).toBe( false );
} );
} );

describe( 'imageGenerationHandler filter', () => {
it( 'should call applyFilters with correct arguments', () => {
mockApplyFilters.mockReturnValue( null );
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { usePostMeta } from '../../../hooks/use-post-meta';
import MediaFocalPoint from '../media-focal-point';

const mockUpdateImageFocalPoint = jest.fn();

jest.mock( '../../../hooks/use-post-meta', () => ( {
usePostMeta: jest.fn(),
} ) );

describe( 'MediaFocalPoint', () => {
beforeEach( () => {
jest.clearAllMocks();
( usePostMeta as jest.Mock ).mockReturnValue( {
imageFocalPoints: {},
updateImageFocalPoint: mockUpdateImageFocalPoint,
} );
} );

it( 'should render the picker with the image and a centered default point', () => {
render( <MediaFocalPoint url="https://example.com/image.jpg" attachmentId={ 123 } /> );

expect( screen.getByRole( 'img' ) ).toHaveAttribute( 'src', 'https://example.com/image.jpg' );
expect( screen.getByRole( 'spinbutton', { name: 'Focal point left position' } ) ).toHaveValue(
50
);
expect( screen.getByRole( 'spinbutton', { name: 'Focal point top position' } ) ).toHaveValue(
50
);
} );

it( 'should use the stored point of this image', () => {
( usePostMeta as jest.Mock ).mockReturnValue( {
imageFocalPoints: { 123: { x: 0.25, y: 0.75 } },
updateImageFocalPoint: mockUpdateImageFocalPoint,
} );

render( <MediaFocalPoint url="https://example.com/image.jpg" attachmentId={ 123 } /> );

expect( screen.getByRole( 'spinbutton', { name: 'Focal point left position' } ) ).toHaveValue(
25
);
expect( screen.getByRole( 'spinbutton', { name: 'Focal point top position' } ) ).toHaveValue(
75
);
} );

it( 'should ignore points stored for other images', () => {
( usePostMeta as jest.Mock ).mockReturnValue( {
imageFocalPoints: { 999: { x: 0.25, y: 0.75 } },
updateImageFocalPoint: mockUpdateImageFocalPoint,
} );

render( <MediaFocalPoint url="https://example.com/image.jpg" attachmentId={ 123 } /> );

expect( screen.getByRole( 'spinbutton', { name: 'Focal point left position' } ) ).toHaveValue(
50
);
expect( screen.getByRole( 'spinbutton', { name: 'Focal point top position' } ) ).toHaveValue(
50
);
} );

it( 'should commit the point for this image when changed', async () => {
const user = userEvent.setup();

render( <MediaFocalPoint url="https://example.com/image.jpg" attachmentId={ 123 } /> );

const leftInput = screen.getByRole( 'spinbutton', { name: 'Focal point left position' } );
await user.clear( leftInput );
await user.type( leftInput, '75' );

expect( mockUpdateImageFocalPoint ).toHaveBeenLastCalledWith( 123, {
x: 0.75,
y: 0.5,
} );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,22 @@ export interface MediaSourceMenuProps {
children?: ( { open }: { open: () => void } ) => React.ReactNode;
}

/**
* Props for MediaFocalPoint component
*/
export interface MediaFocalPointProps {
/**
* URL of the image to pick the focal point on
*/
url: string;

/**
* ID of the attachment whose entry in the image_focal_points map this
* picker reads and writes.
*/
attachmentId: number;
}

/**
* Props for MediaPreview component
*/
Expand Down
Loading
Loading