From b2197bb423b3fd7b35c301cd039ec38ba39894be Mon Sep 17 00:00:00 2001 From: Joel Thiessen <444632+joelclimbsthings@users.noreply.github.com> Date: Tue, 17 Oct 2023 14:27:24 -0700 Subject: [PATCH] [Woo AI] Add background removal feature for the legacy product editor images (#40833) Co-authored-by: Joel Co-authored-by: Thomas Shellberg <6723003+tommyshellberg@users.noreply.github.com> Co-authored-by: Nima Karimi <73110514+nima-karimi@users.noreply.github.com> --- .../add-background-removal-feedback-39730 | 4 + .../changelog/add-image-bkgrnd-removal-hook | 4 + .../changelog/add-remove-background-ui-40440 | 4 + packages/js/ai/src/hooks/index.ts | 1 + .../ai/src/hooks/useBackgroundRemoval.test.ts | 165 +++++++++++++++ .../js/ai/src/hooks/useBackgroundRemoval.ts | 85 ++++++++ packages/js/ai/src/index.ts | 1 + packages/js/ai/src/utils/index.ts | 1 + .../js/ai/src/utils/requestJetpackToken.ts | 87 ++++++++ packages/js/ai/src/utils/text-completion.ts | 84 +------- ...d-background-removal-add-image-count-40743 | 4 + .../add-background-removal-feedback-39730 | 4 + .../add-background-removal-spotlight-40495 | 4 + .../changelog/add-remove-background-ui-40440 | 4 + plugins/woo-ai/src/components/index.ts | 2 +- .../woo-ai/src/components/info-modal/index.ts | 1 - .../src/components/info-modal/info-modal.scss | 34 --- .../src/components/info-modal/info-modal.tsx | 48 ----- .../tour-spotlight/tour-spotlight.scss | 3 + .../tour-spotlight/tour-spotlight.tsx | 122 ++++++----- .../background-removal-link.tsx | 197 ++++++++++++++++++ .../src/image-background-removal/constants.ts | 2 + .../image-background-removal.scss | 46 ++++ .../getCurrentAttachmentDetails.ts | 24 +++ .../image_utils/index.ts | 2 + .../image_utils/uploadImageToLibrary.ts | 83 ++++++++ .../src/image-background-removal/index.tsx | 57 +++++ plugins/woo-ai/src/index.js | 3 + plugins/woo-ai/src/index.scss | 2 + .../product-description-button-container.tsx | 30 ++- plugins/woo-ai/src/utils/productData.ts | 16 ++ 31 files changed, 897 insertions(+), 227 deletions(-) create mode 100644 packages/js/ai/changelog/add-background-removal-feedback-39730 create mode 100644 packages/js/ai/changelog/add-image-bkgrnd-removal-hook create mode 100644 packages/js/ai/changelog/add-remove-background-ui-40440 create mode 100644 packages/js/ai/src/hooks/useBackgroundRemoval.test.ts create mode 100644 packages/js/ai/src/hooks/useBackgroundRemoval.ts create mode 100644 packages/js/ai/src/utils/requestJetpackToken.ts create mode 100644 plugins/woo-ai/changelog/add-background-removal-add-image-count-40743 create mode 100644 plugins/woo-ai/changelog/add-background-removal-feedback-39730 create mode 100644 plugins/woo-ai/changelog/add-background-removal-spotlight-40495 create mode 100644 plugins/woo-ai/changelog/add-remove-background-ui-40440 delete mode 100644 plugins/woo-ai/src/components/info-modal/index.ts delete mode 100644 plugins/woo-ai/src/components/info-modal/info-modal.scss delete mode 100644 plugins/woo-ai/src/components/info-modal/info-modal.tsx create mode 100644 plugins/woo-ai/src/components/tour-spotlight/tour-spotlight.scss create mode 100644 plugins/woo-ai/src/image-background-removal/background-removal-link.tsx create mode 100644 plugins/woo-ai/src/image-background-removal/constants.ts create mode 100644 plugins/woo-ai/src/image-background-removal/image-background-removal.scss create mode 100644 plugins/woo-ai/src/image-background-removal/image_utils/getCurrentAttachmentDetails.ts create mode 100644 plugins/woo-ai/src/image-background-removal/image_utils/index.ts create mode 100644 plugins/woo-ai/src/image-background-removal/image_utils/uploadImageToLibrary.ts create mode 100644 plugins/woo-ai/src/image-background-removal/index.tsx diff --git a/packages/js/ai/changelog/add-background-removal-feedback-39730 b/packages/js/ai/changelog/add-background-removal-feedback-39730 new file mode 100644 index 00000000000..33d2e80af0d --- /dev/null +++ b/packages/js/ai/changelog/add-background-removal-feedback-39730 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding feedback snackbar after image background removal diff --git a/packages/js/ai/changelog/add-image-bkgrnd-removal-hook b/packages/js/ai/changelog/add-image-bkgrnd-removal-hook new file mode 100644 index 00000000000..b33ced9b18b --- /dev/null +++ b/packages/js/ai/changelog/add-image-bkgrnd-removal-hook @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add useBackgroundRemoval hook for image background removal API requests. diff --git a/packages/js/ai/changelog/add-remove-background-ui-40440 b/packages/js/ai/changelog/add-remove-background-ui-40440 new file mode 100644 index 00000000000..29524695a03 --- /dev/null +++ b/packages/js/ai/changelog/add-remove-background-ui-40440 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Reworking error handling and return value for useBackgroundRemoval hook. diff --git a/packages/js/ai/src/hooks/index.ts b/packages/js/ai/src/hooks/index.ts index 41dea0bed94..1452bdff9f1 100644 --- a/packages/js/ai/src/hooks/index.ts +++ b/packages/js/ai/src/hooks/index.ts @@ -1 +1,2 @@ export * from './useCompletion'; +export * from './useBackgroundRemoval'; diff --git a/packages/js/ai/src/hooks/useBackgroundRemoval.test.ts b/packages/js/ai/src/hooks/useBackgroundRemoval.test.ts new file mode 100644 index 00000000000..6d010e4b295 --- /dev/null +++ b/packages/js/ai/src/hooks/useBackgroundRemoval.test.ts @@ -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 ); + } ); +} ); diff --git a/packages/js/ai/src/hooks/useBackgroundRemoval.ts b/packages/js/ai/src/hooks/useBackgroundRemoval.ts new file mode 100644 index 00000000000..d120086a176 --- /dev/null +++ b/packages/js/ai/src/hooks/useBackgroundRemoval.ts @@ -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 }; +}; diff --git a/packages/js/ai/src/index.ts b/packages/js/ai/src/index.ts index 489c950b08e..33144f0c8da 100644 --- a/packages/js/ai/src/index.ts +++ b/packages/js/ai/src/index.ts @@ -4,6 +4,7 @@ export { useCompletion as __experimentalUseCompletion, UseCompletionError, + useBackgroundRemoval as __experimentalUseBackgroundRemoval, } from './hooks'; /** diff --git a/packages/js/ai/src/utils/index.ts b/packages/js/ai/src/utils/index.ts index 3fd24ee2380..e6ccc468370 100644 --- a/packages/js/ai/src/utils/index.ts +++ b/packages/js/ai/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './text-completion'; export * from './create-extended-error'; +export * from './requestJetpackToken'; diff --git a/packages/js/ai/src/utils/requestJetpackToken.ts b/packages/js/ai/src/utils/requestJetpackToken.ts new file mode 100644 index 00000000000..90376d0d6e6 --- /dev/null +++ b/packages/js/ai/src/utils/requestJetpackToken.ts @@ -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' + ); + } +} diff --git a/packages/js/ai/src/utils/text-completion.ts b/packages/js/ai/src/utils/text-completion.ts index 0512c832990..9249e154730 100644 --- a/packages/js/ai/src/utils/text-completion.ts +++ b/packages/js/ai/src/utils/text-completion.ts @@ -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 diff --git a/plugins/woo-ai/changelog/add-background-removal-add-image-count-40743 b/plugins/woo-ai/changelog/add-background-removal-add-image-count-40743 new file mode 100644 index 00000000000..3023b47fdbf --- /dev/null +++ b/plugins/woo-ai/changelog/add-background-removal-add-image-count-40743 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding number of images to tracks events for background removal. diff --git a/plugins/woo-ai/changelog/add-background-removal-feedback-39730 b/plugins/woo-ai/changelog/add-background-removal-feedback-39730 new file mode 100644 index 00000000000..33d2e80af0d --- /dev/null +++ b/plugins/woo-ai/changelog/add-background-removal-feedback-39730 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding feedback snackbar after image background removal diff --git a/plugins/woo-ai/changelog/add-background-removal-spotlight-40495 b/plugins/woo-ai/changelog/add-background-removal-spotlight-40495 new file mode 100644 index 00000000000..fcb13fc36b0 --- /dev/null +++ b/plugins/woo-ai/changelog/add-background-removal-spotlight-40495 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding spotlight to bring attention to background removal link on Media Library. diff --git a/plugins/woo-ai/changelog/add-remove-background-ui-40440 b/plugins/woo-ai/changelog/add-remove-background-ui-40440 new file mode 100644 index 00000000000..c49c9a7d139 --- /dev/null +++ b/plugins/woo-ai/changelog/add-remove-background-ui-40440 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adding background removal for legacy product editor images. diff --git a/plugins/woo-ai/src/components/index.ts b/plugins/woo-ai/src/components/index.ts index d2c6cdcde20..5ef4d294ec1 100644 --- a/plugins/woo-ai/src/components/index.ts +++ b/plugins/woo-ai/src/components/index.ts @@ -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'; diff --git a/plugins/woo-ai/src/components/info-modal/index.ts b/plugins/woo-ai/src/components/info-modal/index.ts deleted file mode 100644 index 3826c03df5e..00000000000 --- a/plugins/woo-ai/src/components/info-modal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './info-modal'; diff --git a/plugins/woo-ai/src/components/info-modal/info-modal.scss b/plugins/woo-ai/src/components/info-modal/info-modal.scss deleted file mode 100644 index 5ee9e113441..00000000000 --- a/plugins/woo-ai/src/components/info-modal/info-modal.scss +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/plugins/woo-ai/src/components/info-modal/info-modal.tsx b/plugins/woo-ai/src/components/info-modal/info-modal.tsx deleted file mode 100644 index 15f2d8c9648..00000000000 --- a/plugins/woo-ai/src/components/info-modal/info-modal.tsx +++ /dev/null @@ -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 ( - - ); -}; diff --git a/plugins/woo-ai/src/components/tour-spotlight/tour-spotlight.scss b/plugins/woo-ai/src/components/tour-spotlight/tour-spotlight.scss new file mode 100644 index 00000000000..03415c6c960 --- /dev/null +++ b/plugins/woo-ai/src/components/tour-spotlight/tour-spotlight.scss @@ -0,0 +1,3 @@ +body.post-type-product .woocommerce-tour-kit { + z-index: 160001; /* value chosen to appear above media library modal */ +} diff --git a/plugins/woo-ai/src/components/tour-spotlight/tour-spotlight.tsx b/plugins/woo-ai/src/components/tour-spotlight/tour-spotlight.tsx index 9ebd3a82f4c..de835666a45 100644 --- a/plugins/woo-ai/src/components/tour-spotlight/tour-spotlight.tsx +++ b/plugins/woo-ai/src/components/tour-spotlight/tour-spotlight.tsx @@ -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 ( -
-

{ title }

-

- { - config.steps[ currentStepIndex ].meta - .description - } -

- -
- ); - }, - tourMinimized: () =>
, - }, - 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 ; -} + return ( + { + setIsSpotlightVisible( false ); + onDismissal(); + }, + } } + /> + ); +}; diff --git a/plugins/woo-ai/src/image-background-removal/background-removal-link.tsx b/plugins/woo-ai/src/image-background-removal/background-removal-link.tsx new file mode 100644 index 00000000000..2bb3270e3c9 --- /dev/null +++ b/plugins/woo-ai/src/image-background-removal/background-removal-link.tsx @@ -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 { __( 'Generating…', 'woocommerce' ) }; + } + + if ( state === 'uploading' ) { + return { __( 'Uploading…', 'woocommerce' ) }; + } + + return ( + <> +
+ + +
+ { ! hasBeenDismissedBefore && ( + Remove backgrounds with AI', + 'woocommerce' + ), + { + NewBlock: ( + + { __( 'NEW', 'woocommerce' ) } + + ), + } + ) } + placement="left" + spotlightParent={ + ( document.querySelector( + `#${ LINK_CONTAINER_ID }` + ) as HTMLElement ) ?? document.body + } + onDismissal={ () => { + recordBgRemovalTracks( 'spotlight_dismissed' ); + setSpotlightAsDismissed(); + } } + onDisplayed={ () => + recordBgRemovalTracks( 'spotlight_displayed' ) + } + /> + ) } + { displayError && ( + setDisplayError( null ) }> + { displayError } + + ) } + + ); +}; diff --git a/plugins/woo-ai/src/image-background-removal/constants.ts b/plugins/woo-ai/src/image-background-removal/constants.ts new file mode 100644 index 00000000000..0f7f2bb8b2c --- /dev/null +++ b/plugins/woo-ai/src/image-background-removal/constants.ts @@ -0,0 +1,2 @@ +export const FILENAME_APPEND = '_no_bg'; +export const LINK_CONTAINER_ID = 'woocommerce-ai-app-remove-background-link'; diff --git a/plugins/woo-ai/src/image-background-removal/image-background-removal.scss b/plugins/woo-ai/src/image-background-removal/image-background-removal.scss new file mode 100644 index 00000000000..a0f814ec854 --- /dev/null +++ b/plugins/woo-ai/src/image-background-removal/image-background-removal.scss @@ -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 */ +} diff --git a/plugins/woo-ai/src/image-background-removal/image_utils/getCurrentAttachmentDetails.ts b/plugins/woo-ai/src/image-background-removal/image_utils/getCurrentAttachmentDetails.ts new file mode 100644 index 00000000000..cf09fcc220e --- /dev/null +++ b/plugins/woo-ai/src/image-background-removal/image_utils/getCurrentAttachmentDetails.ts @@ -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; +}; diff --git a/plugins/woo-ai/src/image-background-removal/image_utils/index.ts b/plugins/woo-ai/src/image-background-removal/image_utils/index.ts new file mode 100644 index 00000000000..693fc3943c0 --- /dev/null +++ b/plugins/woo-ai/src/image-background-removal/image_utils/index.ts @@ -0,0 +1,2 @@ +export * from './uploadImageToLibrary'; +export * from './getCurrentAttachmentDetails'; diff --git a/plugins/woo-ai/src/image-background-removal/image_utils/uploadImageToLibrary.ts b/plugins/woo-ai/src/image-background-removal/image_utils/uploadImageToLibrary.ts new file mode 100644 index 00000000000..5b400c6c23b --- /dev/null +++ b/plugins/woo-ai/src/image-background-removal/image_utils/uploadImageToLibrary.ts @@ -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(); +}; diff --git a/plugins/woo-ai/src/image-background-removal/index.tsx b/plugins/woo-ai/src/image-background-removal/index.tsx new file mode 100644 index 00000000000..13f39701795 --- /dev/null +++ b/plugins/woo-ai/src/image-background-removal/index.tsx @@ -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( ); + } else { + render( , 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; + }, + } + ); +}; diff --git a/plugins/woo-ai/src/index.js b/plugins/woo-ai/src/index.js index 1a284456eca..d4e45d99812 100644 --- a/plugins/woo-ai/src/index.js +++ b/plugins/woo-ai/src/index.js @@ -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 ) => { diff --git a/plugins/woo-ai/src/index.scss b/plugins/woo-ai/src/index.scss index 2e6fffee33f..a5aa730d653 100644 --- a/plugins/woo-ai/src/index.scss +++ b/plugins/woo-ai/src/index.scss @@ -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'; diff --git a/plugins/woo-ai/src/product-description/product-description-button-container.tsx b/plugins/woo-ai/src/product-description/product-description-button-container.tsx index 9e0a0e553a9..3bd2ce39682 100644 --- a/plugins/woo-ai/src/product-description/product-description-button-container.tsx +++ b/plugins/woo-ai/src/product-description/product-description-button-container.tsx @@ -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 && ( - + set( 'woo-ai-plugin', preferenceId, true ) + } /> ) } diff --git a/plugins/woo-ai/src/utils/productData.ts b/plugins/woo-ai/src/utils/productData.ts index b62d3a3d4c4..0fbdd2cdfb3 100644 --- a/plugins/woo-ai/src/utils/productData.ts +++ b/plugins/woo-ai/src/utils/productData.ts @@ -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, + }; +};