[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:
parent
5dedfd7ebe
commit
b2197bb423
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding feedback snackbar after image background removal
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add useBackgroundRemoval hook for image background removal API requests.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Reworking error handling and return value for useBackgroundRemoval hook.
|
|
@ -1 +1,2 @@
|
|||
export * from './useCompletion';
|
||||
export * from './useBackgroundRemoval';
|
||||
|
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
|
@ -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 };
|
||||
};
|
|
@ -4,6 +4,7 @@
|
|||
export {
|
||||
useCompletion as __experimentalUseCompletion,
|
||||
UseCompletionError,
|
||||
useBackgroundRemoval as __experimentalUseBackgroundRemoval,
|
||||
} from './hooks';
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export * from './text-completion';
|
||||
export * from './create-extended-error';
|
||||
export * from './requestJetpackToken';
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding number of images to tracks events for background removal.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding feedback snackbar after image background removal
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding spotlight to bring attention to background removal link on Media Library.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adding background removal for legacy product editor images.
|
|
@ -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';
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
export * from './info-modal';
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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` }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
body.post-type-product .woocommerce-tour-kit {
|
||||
z-index: 160001; /* value chosen to appear above media library modal */
|
||||
}
|
|
@ -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 = {
|
||||
// Avoids showing the spotlight before the layout is ready.
|
||||
useEffect( () => {
|
||||
const timeout = setTimeout( () => {
|
||||
setIsSpotlightVisible( true );
|
||||
}, 250 );
|
||||
|
||||
return () => clearTimeout( timeout );
|
||||
}, [] );
|
||||
|
||||
if ( ! ( anchorElement && isSpotlightVisible ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TourKit
|
||||
config={ {
|
||||
steps: [
|
||||
{
|
||||
referenceElements: {
|
||||
desktop: reference,
|
||||
},
|
||||
meta: {
|
||||
description,
|
||||
name: `woo-ai-feature-spotlight`,
|
||||
heading: title,
|
||||
descriptions: {
|
||||
desktop: description,
|
||||
},
|
||||
primaryButton: {
|
||||
isHidden: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
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>
|
||||
placement,
|
||||
options: {
|
||||
callbacks: {
|
||||
onStepViewOnce: onDisplayed,
|
||||
},
|
||||
portalParentElement: spotlightParent,
|
||||
effects: {
|
||||
liveResize: {
|
||||
mutation: true,
|
||||
resize: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
closeHandler: () => {
|
||||
setIsSpotlightVisible( false );
|
||||
onDismissal();
|
||||
},
|
||||
} }
|
||||
/>
|
||||
);
|
||||
},
|
||||
tourMinimized: () => <div />,
|
||||
},
|
||||
closeHandler: () => handleDismiss(),
|
||||
options: {},
|
||||
};
|
||||
|
||||
if ( ! showTour ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Tour config={ config } />;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export const FILENAME_APPEND = '_no_bg';
|
||||
export const LINK_CONTAINER_ID = 'woocommerce-ai-app-remove-background-link';
|
|
@ -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 */
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './uploadImageToLibrary';
|
||||
export * from './getCurrentAttachmentDetails';
|
|
@ -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();
|
||||
};
|
|
@ -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;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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 ) => {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 )
|
||||
}
|
||||
/>
|
||||
) }
|
||||
</>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue