[Woo AI] Add background removal feature for the legacy product editor images (#40833)

Co-authored-by: Joel <dygerati@gmail.com>
Co-authored-by: Thomas Shellberg <6723003+tommyshellberg@users.noreply.github.com>
Co-authored-by: Nima Karimi <73110514+nima-karimi@users.noreply.github.com>
This commit is contained in:
Joel Thiessen 2023-10-17 14:27:24 -07:00 committed by GitHub
parent 5dedfd7ebe
commit b2197bb423
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 897 additions and 227 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding feedback snackbar after image background removal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add useBackgroundRemoval hook for image background removal API requests.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Reworking error handling and return value for useBackgroundRemoval hook.

View File

@ -1 +1,2 @@
export * from './useCompletion';
export * from './useBackgroundRemoval';

View File

@ -0,0 +1,165 @@
/**
* External dependencies
*/
import { renderHook, act } from '@testing-library/react-hooks/dom';
import { waitFor } from '@testing-library/react';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import {
BackgroundRemovalParams,
useBackgroundRemoval,
} from './useBackgroundRemoval';
import { requestJetpackToken } from '../utils/requestJetpackToken';
// Mocking the apiFetch function
jest.mock( '@wordpress/api-fetch', () =>
jest.fn().mockResolvedValue( {
blob: () =>
Promise.resolve(
new Blob( [ new ArrayBuffer( 51200 ) ], {
type: 'image/jpeg',
} )
),
} )
);
jest.mock( '../utils/requestJetpackToken' );
const mockedRequestJetpackToken = requestJetpackToken as jest.MockedFunction<
typeof requestJetpackToken
>;
describe( 'useBackgroundRemoval hook', () => {
let mockRequestParams: BackgroundRemovalParams;
beforeEach( () => {
jest.resetAllMocks();
// Initialize with valid parameters (50kb image file).
const imageFile = new File( [ new ArrayBuffer( 51200 ) ], 'test.png', {
type: 'image/png',
} );
mockRequestParams = {
imageFile,
};
mockedRequestJetpackToken.mockResolvedValue( { token: 'fake_token' } );
} );
it( 'should initialize with correct default values', () => {
const { result } = renderHook( () => useBackgroundRemoval() );
expect( result.current.imageData ).toBeNull();
expect( result.current.loading ).toBeFalsy();
} );
it( 'should return error on empty token', async () => {
mockedRequestJetpackToken.mockResolvedValue( { token: '' } );
const { result } = renderHook( () => useBackgroundRemoval() );
await expect(
act( async () => {
await result.current.fetchImage( mockRequestParams );
} )
).rejects.toThrow( 'Invalid token' );
} );
it( 'should handle invalid file type', async () => {
mockRequestParams.imageFile = new File(
[ new ArrayBuffer( 51200 ) ],
'test.txt',
{ type: 'text/plain' }
);
const { result } = renderHook( () => useBackgroundRemoval() );
await expect(
act( async () => {
await result.current.fetchImage( mockRequestParams );
} )
).rejects.toThrow( 'Invalid image file' );
} );
it( 'should return error on image file too small', async () => {
mockRequestParams.imageFile = new File(
[ new ArrayBuffer( 1024 ) ],
'test.png',
{ type: 'image/png' }
); // 1KB
const { result } = renderHook( () => useBackgroundRemoval() );
await expect(
act( async () => {
await result.current.fetchImage( mockRequestParams );
} )
).rejects.toThrow( 'Image file too small, must be at least 5KB' );
} );
it( 'should return error on image file too large', async () => {
mockRequestParams.imageFile = new File(
[ new ArrayBuffer( 10240 * 1024 * 2 ) ],
'test.png',
{ type: 'image/png' }
); // 10MB
const { result } = renderHook( () => useBackgroundRemoval() );
await expect(
act( async () => {
await result.current.fetchImage( mockRequestParams );
} )
).rejects.toThrow( 'Image file too large, must be under 10MB' );
} );
it( 'should set loading to true when fetchImage is called', async () => {
(
apiFetch as jest.MockedFunction< typeof apiFetch >
).mockResolvedValue( {
blob: () =>
Promise.resolve(
new Blob( [ new ArrayBuffer( 51200 ) ], {
type: 'image/jpeg',
} )
),
} );
const { result } = renderHook( () => useBackgroundRemoval() );
await act( async () => {
result.current.fetchImage( mockRequestParams );
await waitFor( () =>
expect( result.current.loading ).toBeTruthy()
);
} );
expect( mockedRequestJetpackToken ).toHaveBeenCalled();
} );
it( 'should handle successful API call', async () => {
(
apiFetch as jest.MockedFunction< typeof apiFetch >
).mockResolvedValue( {
blob: () =>
Promise.resolve(
new Blob( [ new ArrayBuffer( 51200 ) ], {
type: 'image/jpeg',
} )
),
} );
const { result } = renderHook( () => useBackgroundRemoval() );
await act( async () => {
await result.current.fetchImage( mockRequestParams );
} );
expect( result.current.loading ).toBe( false );
expect( result.current.imageData ).toBeInstanceOf( Blob );
} );
it( 'should handle API errors', async () => {
(
apiFetch as jest.MockedFunction< typeof apiFetch >
).mockImplementation( () => {
throw new Error( 'API Error' );
} );
const { result } = renderHook( () => useBackgroundRemoval() );
await expect(
act( async () => {
await result.current.fetchImage( mockRequestParams );
} )
).rejects.toThrow( 'API Error' );
await waitFor( () => expect( result.current.loading ).toBeFalsy() );
expect( result.current.imageData ).toBe( null );
} );
} );

View File

@ -0,0 +1,85 @@
/**
* External dependencies
*/
import { useState } from 'react';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { createExtendedError } from '../utils/create-extended-error';
import { requestJetpackToken } from '../utils/requestJetpackToken';
export type BackgroundRemovalParams = {
imageFile: File;
};
type BackgroundRemovalResponse = {
loading: boolean;
imageData: Blob | null;
fetchImage: ( params: BackgroundRemovalParams ) => Promise< Blob >;
};
export const useBackgroundRemoval = (): BackgroundRemovalResponse => {
const [ loading, setLoading ] = useState( false );
const [ imageData, setImageData ] = useState< Blob | null >( null );
const fetchImage = async ( params: BackgroundRemovalParams ) => {
setLoading( true );
const { imageFile } = params;
const { token } = await requestJetpackToken();
if ( ! token ) {
throw createExtendedError( 'Invalid token', 'invalid_jwt' );
}
// Validate that the file is an image and has actual content.
if ( ! imageFile.type.startsWith( 'image/' ) ) {
throw createExtendedError(
'Invalid image file',
'invalid_image_file'
);
}
const fileSizeInKB = params.imageFile.size / 1024;
if ( fileSizeInKB < 5 ) {
throw createExtendedError(
'Image file too small, must be at least 5KB',
'image_file_too_small'
);
}
// The WPCOM REST API endpoint has a 10MB image size limit.
if ( fileSizeInKB > 10240 ) {
throw createExtendedError(
'Image file too large, must be under 10MB',
'image_file_too_large'
);
}
const formData = new FormData();
formData.append( 'image_file', imageFile );
formData.append( 'token', token );
try {
const response = await apiFetch( {
url: 'https://public-api.wordpress.com/wpcom/v2/ai-background-removal',
method: 'POST',
body: formData,
parse: false,
credentials: 'omit',
} );
const blob = await (
response as { blob: () => Promise< Blob > }
).blob();
setImageData( blob );
return blob;
} catch ( err ) {
throw err;
} finally {
setLoading( false );
}
};
return { loading, imageData, fetchImage };
};

View File

@ -4,6 +4,7 @@
export {
useCompletion as __experimentalUseCompletion,
UseCompletionError,
useBackgroundRemoval as __experimentalUseBackgroundRemoval,
} from './hooks';
/**

View File

@ -1,2 +1,3 @@
export * from './text-completion';
export * from './create-extended-error';
export * from './requestJetpackToken';

View File

@ -0,0 +1,87 @@
/**
* External dependencies
*/
import debugFactory from 'debug';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { createExtendedError } from './create-extended-error';
export const debugToken = debugFactory( 'jetpack-ai-assistant:token' );
export const JWT_TOKEN_ID = 'jetpack-ai-jwt-token';
export const JWT_TOKEN_EXPIRATION_TIME = 2 * 60 * 1000;
declare global {
interface Window {
JP_CONNECTION_INITIAL_STATE: {
apiNonce: string;
siteSuffix: string;
connectionStatus: { isActive: boolean };
};
}
}
/**
* Request a token from the Jetpack site to use with the API
*
* @return {Promise<{token: string, blogId: string}>} The token and the blogId
*/
export async function requestJetpackToken() {
const token = localStorage.getItem( JWT_TOKEN_ID );
let tokenData;
if ( token ) {
try {
tokenData = JSON.parse( token );
} catch ( err ) {
debugToken( 'Error parsing token', err );
throw createExtendedError(
'Error parsing cached token',
'token_parse_error'
);
}
}
if ( tokenData && tokenData?.expire > Date.now() ) {
debugToken( 'Using cached token' );
return tokenData;
}
const apiNonce = window.JP_CONNECTION_INITIAL_STATE?.apiNonce;
const siteSuffix = window.JP_CONNECTION_INITIAL_STATE?.siteSuffix;
try {
const data: { token: string; blog_id: string } = await apiFetch( {
path: '/jetpack/v4/jetpack-ai-jwt?_cacheBuster=' + Date.now(),
credentials: 'same-origin',
headers: {
'X-WP-Nonce': apiNonce,
},
method: 'POST',
} );
const newTokenData = {
token: data.token,
blogId: siteSuffix,
/**
* Let's expire the token in 2 minutes
*/
expire: Date.now() + JWT_TOKEN_EXPIRATION_TIME,
};
debugToken( 'Storing new token' );
localStorage.setItem( JWT_TOKEN_ID, JSON.stringify( newTokenData ) );
return newTokenData;
} catch ( e ) {
throw createExtendedError(
'Error fetching new token',
'token_fetch_error'
);
}
}

View File

@ -1,89 +1,7 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import debugFactory from 'debug';
/**
* Internal dependencies
*/
import { createExtendedError } from './create-extended-error';
const debugToken = debugFactory( 'jetpack-ai-assistant:token' );
const JWT_TOKEN_ID = 'jetpack-ai-jwt-token';
const JWT_TOKEN_EXPIRATION_TIME = 2 * 60 * 1000;
declare global {
interface Window {
JP_CONNECTION_INITIAL_STATE: {
apiNonce: string;
siteSuffix: string;
connectionStatus: { isActive: boolean };
};
}
}
/**
* Request a token from the Jetpack site to use with the API
*
* @return {Promise<{token: string, blogId: string}>} The token and the blogId
*/
export async function requestJetpackToken() {
const token = localStorage.getItem( JWT_TOKEN_ID );
let tokenData;
if ( token ) {
try {
tokenData = JSON.parse( token );
} catch ( err ) {
debugToken( 'Error parsing token', err );
throw createExtendedError(
'Error parsing cached token',
'token_parse_error'
);
}
}
if ( tokenData && tokenData?.expire > Date.now() ) {
debugToken( 'Using cached token' );
return tokenData;
}
const apiNonce = window.JP_CONNECTION_INITIAL_STATE?.apiNonce;
const siteSuffix = window.JP_CONNECTION_INITIAL_STATE?.siteSuffix;
try {
const data: { token: string; blog_id: string } = await apiFetch( {
path: '/jetpack/v4/jetpack-ai-jwt?_cacheBuster=' + Date.now(),
credentials: 'same-origin',
headers: {
'X-WP-Nonce': apiNonce,
},
method: 'POST',
} );
const newTokenData = {
token: data.token,
blogId: siteSuffix,
/**
* Let's expire the token in 2 minutes
*/
expire: Date.now() + JWT_TOKEN_EXPIRATION_TIME,
};
debugToken( 'Storing new token' );
localStorage.setItem( JWT_TOKEN_ID, JSON.stringify( newTokenData ) );
return newTokenData;
} catch ( e ) {
throw createExtendedError(
'Error fetching new token',
'token_fetch_error'
);
}
}
import { requestJetpackToken } from './requestJetpackToken';
/**
* Leaving this here to make it easier to debug the streaming API calls for now

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding number of images to tracks events for background removal.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding feedback snackbar after image background removal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding spotlight to bring attention to background removal link on Media Library.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Adding background removal for legacy product editor images.

View File

@ -2,4 +2,4 @@ export * from './random-loading-message';
export * from './description-completion-buttons';
export * from './magic-button';
export * from './suggestion-pills';
export * from './info-modal';
export * from './tour-spotlight';

View File

@ -1 +0,0 @@
export * from './info-modal';

View File

@ -1,34 +0,0 @@
.shortDescriptionGenerated-spotlight {
max-width: 400px;
padding:20px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
span {
font-weight: 600;
font-size: 18px;
}
p {
font-size:16px;
margin-bottom: 20px;
}
button {
padding: 10px 15px;
border-radius: 5px;
color:#fff;
background-color: #0074a2;
border: 1px solid lightblue;
float:right;
border:none;
margin-bottom:20px;
&:hover, &:focus {
background-color: darken(#0074a2, 10%); // Slightly darken the blue on hover and focus
}
&:active {
background-color: darken(#0074a2, 15%); // Darken a bit more on active state
}
}
}

View File

@ -1,48 +0,0 @@
/**
* External dependencies
*/
import { useDispatch, select } from '@wordpress/data';
import { store as preferencesStore } from '@wordpress/preferences';
import React from 'react';
/**
* Internal dependencies
*/
import TourSpotlight from '../tour-spotlight/tour-spotlight';
import './info-modal.scss';
interface InfoModalProps {
id: string;
message: string;
title: string;
}
export const InfoModal: React.FC< InfoModalProps > = ( {
id,
message,
title,
} ) => {
const anchorElement = document.querySelector( '#postexcerpt' );
const hasBeenDismissedBefore = select( preferencesStore ).get(
'woo-ai-plugin',
`modalDismissed-${ id }`
);
const { set } = useDispatch( preferencesStore );
if ( ! anchorElement || hasBeenDismissedBefore ) return null;
const closeTour = () => {
set( 'woo-ai-plugin', `modalDismissed-${ id }`, true );
};
return (
<TourSpotlight
title={ title }
description={ message }
onDismiss={ closeTour }
reference={ '#postexcerpt' }
className={ `${ id }-spotlight` }
/>
);
};

View File

@ -0,0 +1,3 @@
body.post-type-product .woocommerce-tour-kit {
z-index: 160001; /* value chosen to appear above media library modal */
}

View File

@ -1,71 +1,85 @@
/**
* External dependencies
*/
import Tour, { TourStepRendererProps } from '@automattic/tour-kit';
import { Button } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import React from 'react';
import { TourKit } from '@woocommerce/components';
import { useState, useEffect } from '@wordpress/element';
import { Config } from '@automattic/tour-kit';
type TourSpotlightProps = {
onDismiss: () => void;
title: string;
id: string;
title: string | React.ReactElement;
description: string;
reference: string;
className?: string;
placement?: Config[ 'placement' ];
spotlightParent?: HTMLElement;
onDismissal?: () => void;
onDisplayed?: () => void;
};
export default function TourSpotlight( {
onDismiss,
export const TourSpotlight: React.FC< TourSpotlightProps > = ( {
title,
description,
reference,
className,
}: TourSpotlightProps ) {
const [ showTour, setShowTour ] = useState( true );
placement = 'auto',
spotlightParent = document.body,
onDismissal = () => {},
onDisplayed = () => {},
} ) => {
const anchorElement = document.querySelector( reference );
const [ isSpotlightVisible, setIsSpotlightVisible ] =
useState< boolean >( false );
const handleDismiss = () => {
setShowTour( false );
onDismiss();
};
// Define a configuration for the tour, passing along a handler for closing.
const config = {
steps: [
{
referenceElements: {
desktop: reference,
},
meta: {
description,
},
},
],
renderers: {
tourStep: ( { currentStepIndex }: TourStepRendererProps ) => {
return (
<div className={ className }>
<h3>{ title }</h3>
<p>
{
config.steps[ currentStepIndex ].meta
.description
}
</p>
<Button onClick={ handleDismiss }>
{ __( 'Got it', 'woocommerce' ) }
</Button>
</div>
);
},
tourMinimized: () => <div />,
},
closeHandler: () => handleDismiss(),
options: {},
};
// Avoids showing the spotlight before the layout is ready.
useEffect( () => {
const timeout = setTimeout( () => {
setIsSpotlightVisible( true );
}, 250 );
if ( ! showTour ) {
return () => clearTimeout( timeout );
}, [] );
if ( ! ( anchorElement && isSpotlightVisible ) ) {
return null;
}
return <Tour config={ config } />;
}
return (
<TourKit
config={ {
steps: [
{
referenceElements: {
desktop: reference,
},
meta: {
name: `woo-ai-feature-spotlight`,
heading: title,
descriptions: {
desktop: description,
},
primaryButton: {
isHidden: true,
},
},
},
],
placement,
options: {
callbacks: {
onStepViewOnce: onDisplayed,
},
portalParentElement: spotlightParent,
effects: {
liveResize: {
mutation: true,
resize: true,
},
},
},
closeHandler: () => {
setIsSpotlightVisible( false );
onDismissal();
},
} }
/>
);
};

View File

@ -0,0 +1,197 @@
/**
* External dependencies
*/
import { useState, useEffect } from 'react';
import { __experimentalUseBackgroundRemoval as useBackgroundRemoval } from '@woocommerce/ai';
import { store as preferencesStore } from '@wordpress/preferences';
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import { Notice } from '@wordpress/components';
import { useDispatch, select } from '@wordpress/data';
/**
* Internal dependencies
*/
import MagicIcon from '../../assets/images/icons/magic.svg';
import { FILENAME_APPEND, LINK_CONTAINER_ID } from './constants';
import { useFeedbackSnackbar } from '../hooks';
import { recordTracksFactory, getPostId, getProductImageCount } from '../utils';
import {
uploadImageToLibrary,
getCurrentAttachmentDetails,
} from './image_utils';
import { TourSpotlight } from '../components/';
const preferenceId = `spotlightDismissed-backgroundRemovalLink`;
const getErrorMessage = ( errorCode?: string ) => {
switch ( errorCode ) {
case 'invalid_image_file':
return __( 'Invalid image', 'woocommerce' );
case 'image_file_too_small':
return __( 'Image too small', 'woocommerce' );
case 'image_file_too_large':
return __( 'Image too large', 'woocommerce' );
default:
return __( 'Something went wrong', 'woocommerce' );
}
};
const recordBgRemovalTracks = recordTracksFactory(
'background_removal',
() => ( {
post_id: getPostId(),
image_count: getProductImageCount().total,
} )
);
export const BackgroundRemovalLink = () => {
const { fetchImage } = useBackgroundRemoval();
const { showSnackbar, removeSnackbar } = useFeedbackSnackbar();
const hasBeenDismissedBefore = select( preferencesStore ).get(
'woo-ai-plugin',
preferenceId
);
const { set } = useDispatch( preferencesStore );
const [ state, setState ] = useState< 'none' | 'generating' | 'uploading' >(
'none'
);
const [ displayError, setDisplayError ] = useState< string | null >( null );
useEffect( () => {
recordBgRemovalTracks( 'view_ui' );
}, [] );
const setSpotlightAsDismissed = () =>
set( 'woo-ai-plugin', preferenceId, true );
const onRemoveBackgroundClick = async () => {
removeSnackbar();
try {
recordBgRemovalTracks( 'click' );
setState( 'generating' );
const { url: imgUrl, filename: imgFilename } =
getCurrentAttachmentDetails();
if ( ! imgUrl ) {
setDisplayError( getErrorMessage() );
return;
}
const originalBlob = await fetch( imgUrl ).then( ( res ) =>
res.blob()
);
const bgRemoved = await fetchImage( {
imageFile: new File( [ originalBlob ], imgFilename ?? '', {
type: originalBlob.type,
} ),
} );
setState( 'uploading' );
await uploadImageToLibrary( {
imageBlob: bgRemoved,
libraryFilename: `${ imgFilename }${ FILENAME_APPEND }.${ bgRemoved.type
.split( '/' )
.pop() }`,
} );
recordBgRemovalTracks( 'complete' );
setSpotlightAsDismissed();
showSnackbar( {
label: __( 'Was the generated image helpful?', 'woocommerce' ),
onPositiveResponse: () => {
recordBgRemovalTracks( 'feedback', {
response: 'positive',
} );
},
onNegativeResponse: () => {
recordBgRemovalTracks( 'feedback', {
response: 'negative',
} );
},
} );
} catch ( err ) {
//eslint-disable-next-line no-console
console.error( err );
const { message: errMessage, code: errCode } = err as {
code?: string;
message?: string;
};
setDisplayError( getErrorMessage( errCode ) );
recordBgRemovalTracks( 'error', {
code: errCode ?? null,
message: errMessage ?? null,
} );
} finally {
setState( 'none' );
}
};
if ( state === 'generating' ) {
return <span>{ __( 'Generating…', 'woocommerce' ) }</span>;
}
if ( state === 'uploading' ) {
return <span>{ __( 'Uploading…', 'woocommerce' ) }</span>;
}
return (
<>
<div className="background-link_actions">
<button onClick={ () => onRemoveBackgroundClick() }>
{ __( 'Remove background', 'woocommerce' ) }
</button>
<img src={ MagicIcon } alt="" />
</div>
{ ! hasBeenDismissedBefore && (
<TourSpotlight
id="backgroundRemovalLink"
reference={ `#${ LINK_CONTAINER_ID }` }
description={ __(
'Effortlessly make your product images pop by removing the background using state-of-the-art AI technology. Just click the button and watch!',
'woocommerce'
) }
title={ createInterpolateElement(
__(
'<NewBlock /> Remove backgrounds with AI',
'woocommerce'
),
{
NewBlock: (
<span className="woo-ai-background-removal-link__new-block">
{ __( 'NEW', 'woocommerce' ) }
</span>
),
}
) }
placement="left"
spotlightParent={
( document.querySelector(
`#${ LINK_CONTAINER_ID }`
) as HTMLElement ) ?? document.body
}
onDismissal={ () => {
recordBgRemovalTracks( 'spotlight_dismissed' );
setSpotlightAsDismissed();
} }
onDisplayed={ () =>
recordBgRemovalTracks( 'spotlight_displayed' )
}
/>
) }
{ displayError && (
<Notice onRemove={ () => setDisplayError( null ) }>
{ displayError }
</Notice>
) }
</>
);
};

View File

@ -0,0 +1,2 @@
export const FILENAME_APPEND = '_no_bg';
export const LINK_CONTAINER_ID = 'woocommerce-ai-app-remove-background-link';

View File

@ -0,0 +1,46 @@
#woocommerce-ai-app-remove-background-link {
.woo-ai-background-removal-link__new-block {
font-size: 0.8em;
margin: 0 5px;
background-color: #fdf9e8;
font-weight: 400;
padding: 2px 3px;
}
.components-notice {
padding: 0 0 0 5px;
margin: 5px 0 0 0;
font-size: .8em;
max-width: 125px;
.components-button.has-icon {
width: 25px;
min-width: unset;
}
}
.background-link_actions {
display: flex;
> button {
color: #2271b1;
padding: 0;
border: 0;
background: none;
cursor: pointer;
}
> img {
height: 16px;
}
}
.woocommerce-tour-kit-step__heading {
text-transform: none;
}
}
body.post-type-product .woocommerce-layout__footer {
z-index: 160001; /* value chosen to appear above media library modal */
}

View File

@ -0,0 +1,24 @@
type AttachmentDetails = {
url?: string;
filename?: string;
};
export const getCurrentAttachmentDetails = (
node: HTMLElement | Document = document
): AttachmentDetails => {
const details: AttachmentDetails = {};
const url = (
node.querySelector(
'.attachment-details-copy-link'
) as HTMLInputElement | null
)?.value;
if ( url ) {
details.url = url;
}
details.filename = url?.split( '/' ).pop()?.split( '.' )[ 0 ] ?? '';
return details;
};

View File

@ -0,0 +1,2 @@
export * from './uploadImageToLibrary';
export * from './getCurrentAttachmentDetails';

View File

@ -0,0 +1,83 @@
/**
* External dependencies
*/
import { createExtendedError } from '@woocommerce/ai';
type BackgroundRemovalParams = {
imageBlob: Blob;
libraryFilename: string;
};
declare global {
interface Window {
ajaxurl?: string;
}
}
export const uploadImageToLibrary = async ( {
imageBlob,
libraryFilename,
}: BackgroundRemovalParams ) => {
const fileObj = new File( [ imageBlob ], libraryFilename );
let _fileId: number | null = null;
await wp.mediaUtils.uploadMedia( {
allowedTypes: 'image',
filesList: [ fileObj ],
onError: ( e: Error ) => {
throw e;
},
onFileChange: ( files: Array< { id: number } > ) => {
if ( files.length > 0 && files[ 0 ]?.id ) {
_fileId = files[ 0 ].id;
}
},
} );
if ( _fileId === null ) {
return;
}
const nonceValue = window?.JP_CONNECTION_INITIAL_STATE.apiNonce ?? '';
const ajaxUrl = window?.ajaxurl;
if ( ! ( ajaxUrl && nonceValue ) ) {
throw createExtendedError(
'Missing nonce or ajaxurl',
'missing_nonce'
);
}
const formData = new FormData();
formData.append( 'action', 'get-attachment' );
formData.append( 'id', String( _fileId ) );
formData.append( '_ajax_nonce', nonceValue );
const response = await fetch( ajaxUrl, {
method: 'POST',
body: formData,
} ).then( ( res ) => res.json() );
if ( ! response.data ) {
throw createExtendedError(
'Invalid response from ajax request',
'invalid_response'
);
}
const attachmentData = wp.media.model.Attachment.create( {
...response.data,
file: fileObj,
uploading: false,
date: new Date(),
filename: fileObj.name,
menuOrder: 0,
type: 'image',
uploadedTo: wp.media.model.settings.post.id,
} );
wp.media.model.Attachment.get( _fileId, attachmentData );
wp.Uploader.queue.add( attachmentData );
wp.Uploader.queue.reset();
};

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore createRoot included for future compatibility
// eslint-disable-next-line @woocommerce/dependency-group
import { render, createRoot } from '@wordpress/element';
/**
* Internal dependencies
*/
import { BackgroundRemovalLink } from './background-removal-link';
import { getCurrentAttachmentDetails } from './image_utils';
import { FILENAME_APPEND, LINK_CONTAINER_ID } from './constants';
export const init = () => {
const _previous = wp.media.view.Attachment.Details.prototype;
wp.media.view.Attachment.Details = wp.media.view.Attachment.Details.extend(
{
initialize() {
_previous.initialize.call( this );
setTimeout( () => {
const root = document.body.querySelector(
`#${ LINK_CONTAINER_ID }`
);
if ( ! root ) {
return;
}
if ( createRoot ) {
createRoot( root ).render( <BackgroundRemovalLink /> );
} else {
render( <BackgroundRemovalLink />, root );
}
}, 0 );
},
template( view: { id: number } ) {
const previousHtml = _previous.template.call( this, view );
const dom = document.createElement( 'div' );
dom.innerHTML = previousHtml;
const attachmentDetails = getCurrentAttachmentDetails( dom );
if ( attachmentDetails.filename?.includes( FILENAME_APPEND ) ) {
return dom.innerHTML;
}
const details = dom.querySelector( '.details' );
const reactApp = document.createElement( 'div' );
reactApp.id = LINK_CONTAINER_ID;
details?.appendChild( reactApp );
return dom.innerHTML;
},
}
);
};

View File

@ -12,12 +12,15 @@ import { ProductNameSuggestions } from './product-name';
import { ProductCategorySuggestions } from './product-category';
import { WriteShortDescriptionButtonContainer } from './product-short-description';
import setPreferencesPersistence from './utils/preferencesPersistence';
import { init as initBackgroundRemoval } from './image-background-removal';
import './index.scss';
// This sets up loading and saving the plugin's preferences.
setPreferencesPersistence();
initBackgroundRemoval();
const queryClient = new QueryClient();
const renderComponent = ( Component, rootElement ) => {

View File

@ -3,3 +3,5 @@
@import "product-name/product-name.scss";
@import 'product-category/product-category.scss';
@import 'product-short-description/product-short-description.scss';
@import 'image-background-removal/image-background-removal.scss';
@import 'components/tour-spotlight/tour-spotlight.scss';

View File

@ -1,8 +1,9 @@
/**
* External dependencies
*/
import { useDispatch } from '@wordpress/data';
import { useDispatch, select } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
import { store as preferencesStore } from '@wordpress/preferences';
import { __, sprintf } from '@wordpress/i18n';
import { useState, useEffect, useRef } from '@wordpress/element';
import {
@ -19,7 +20,11 @@ import {
DESCRIPTION_MAX_LENGTH,
WOO_AI_PLUGIN_FEATURE_NAME,
} from '../constants';
import { InfoModal, StopCompletionBtn, WriteItForMeBtn } from '../components';
import {
StopCompletionBtn,
WriteItForMeBtn,
TourSpotlight,
} from '../components';
import { useFeedbackSnackbar, useStoreBranding, useTinyEditor } from '../hooks';
import {
getProductName,
@ -33,6 +38,8 @@ import { Attribute } from '../utils/types';
import { translateApiErrors as getApiError } from '../utils/apiErrors';
import { buildShortDescriptionPrompt } from '../product-short-description/product-short-description-button-container';
const preferenceId = 'modalDismissed-shortDescriptionGenerated';
const recordDescriptionTracks = recordTracksFactory(
'description_completion',
() => ( {
@ -53,6 +60,12 @@ export function WriteItForMeButtonContainer() {
titleEl.current?.value || ''
);
const hasBeenDismissedBefore = select( preferencesStore ).get(
'woo-ai-plugin',
preferenceId
);
const { set } = useDispatch( preferencesStore );
const { createErrorNotice } = useDispatch( noticesStore );
const [ errorNoticeDismissed, setErrorNoticeDismissed ] = useState( false );
const { data: brandingData } = useStoreBranding( {
@ -248,7 +261,7 @@ export function WriteItForMeButtonContainer() {
try {
await requestCompletion( prompt );
const longDescription = tinyEditor.getContent();
if ( ! longDescription || shortDescriptionGenerated ) {
if ( ! shortTinyEditor.getContent() || shortDescriptionGenerated ) {
const shortDescriptionPrompt =
buildShortDescriptionPrompt( longDescription );
await requestShortCompletion( shortDescriptionPrompt );
@ -275,16 +288,21 @@ export function WriteItForMeButtonContainer() {
MIN_TITLE_LENGTH_FOR_DESCRIPTION
) }
/>
{ shortDescriptionGenerated && (
<InfoModal
{ shortDescriptionGenerated && ! hasBeenDismissedBefore && (
<TourSpotlight
id="shortDescriptionGenerated"
reference="#postexcerpt"
// message should be translatable.
message={ __(
description={ __(
'The short description was automatically generated by AI using the long description. This normally appears at the top of your product pages.',
'woocommerce'
) }
// title should also be translatable.
title={ __( 'Short Description Generated', 'woocommerce' ) }
placement="top"
onDismissal={ () =>
set( 'woo-ai-plugin', preferenceId, true )
}
/>
) }
</>

View File

@ -190,3 +190,19 @@ export const isProductVirtual = () =>
export const isProductDownloadable = () =>
( document.querySelector( '#_downloadable' ) as HTMLInputElement )?.checked;
export const getProductImageCount = () => {
const gallery = document.querySelectorAll(
'.product_images li.image'
).length;
const featured = document.querySelectorAll(
'#set-post-thumbnail img'
).length;
return {
gallery,
featured,
total: gallery + featured,
};
};