Skip to content
1 change: 1 addition & 0 deletions includes/Experiments/Title_Generation/Title_Generation.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public function enqueue_assets( string $hook_suffix ): void {
}

Asset_Loader::enqueue_script( 'title_generation', 'experiments/title-generation' );
Asset_Loader::enqueue_style( 'title_generation', 'experiments/title-generation' );
Asset_Loader::localize_script(
'title_generation',
'TitleGenerationData',
Expand Down
188 changes: 142 additions & 46 deletions src/experiments/title-generation/components/TitleToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,87 +33,119 @@ import type {
const { aiTitleGenerationData } = window as any;

/**
* Renders a single title option.
* Renders a single title option as a choice card with a radio button.
*
* @param {Object} props Component props.
* @param {string} props.title The title value to display.
* @param {number} props.index The index of this title in the array.
* @param {Function} props.onChange Callback to update this title's value.
* @param {Function} props.onSelect Callback when this title is selected.
* @param {Object} props Component props.
* @param {string} props.title The title value to display.
* @param {boolean} props.isSelected Whether this option is currently selected.
* @param {boolean} props.isDisabled Whether controls are disabled (e.g. during regeneration).
* @param {Function} props.onChange Callback to update this title's value.
* @param {Function} props.onSelect Callback to select this option.
* @return {JSX.Element} The rendered title option.
*/
function TitleOption( {
title,
index,
isSelected,
isDisabled,
onChange,
onSelect,
}: {
title: string;
index: number;
isSelected: boolean;
isDisabled: boolean;
onChange: ( value: string ) => void;
onSelect: ( title: string, index: number ) => void;
onSelect: () => void;
} ): JSX.Element {
return (
<FlexItem className="ai-title">
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={ [
'ai-title-generation-option',
isSelected && 'is-selected',
isDisabled && 'is-disabled',
]
.filter( Boolean )
.join( ' ' ) }
onClick={ ! isDisabled ? onSelect : undefined }
>
<input
type="radio"
name="ai-title-selection"
checked={ isSelected }
onChange={ onSelect }
disabled={ isDisabled }
className="ai-title-generation-option__radio"
aria-label={ title }
/>
<TextareaControl
rows={ 2 }
label={ __( 'Generated title', 'ai' ) }
hideLabelFromVision
value={ title }
onChange={ onChange }
onChange={ ( value: string ) => {
if ( ! isSelected ) {
onSelect();
}
onChange( value );
} }
disabled={ isDisabled }
__nextHasNoMarginBottom
/>
<Button
variant="secondary"
style={ { marginTop: '15px' } }
onClick={ () => onSelect( title, index ) }
>
{ __( 'Select', 'ai' ) }
</Button>
</FlexItem>
</div>
);
}

/**
* Renders the generated title data with editable textareas.
* Renders the list of generated title options as a radio group.
*
* @param {Object} props Component props.
* @param {string[]} props.titles The array of titles to render.
* @param {Function} props.onTitleChange Callback to update the title array.
* @param {Function} props.onSelect Callback when a title is selected.
* @return {JSX.Element | null} The rendered titles.
* @param {number} props.selectedIndex Index of the currently selected title.
* @param {boolean} props.isDisabled Whether controls are disabled.
* @param {Function} props.onTitleChange Callback to update the titles array.
* @param {Function} props.onSelect Callback when an option is selected.
* @return {JSX.Element | null} The rendered title options.
*/
function TitleOptionsList( {
titles: titlesToRender,
titles,
selectedIndex,
isDisabled,
onTitleChange,
onSelect,
}: {
titles: string[];
onTitleChange: ( newTitle: string[] ) => void;
onSelect: ( title: string, index: number ) => void;
selectedIndex: number;
isDisabled: boolean;
onTitleChange: ( newTitles: string[] ) => void;
onSelect: ( index: number ) => void;
} ): JSX.Element | null {
if ( ! titlesToRender || titlesToRender.length === 0 ) {
if ( ! titles || titles.length === 0 ) {
return null;
}

return (
<Flex gap="5" wrap direction="column">
{ titlesToRender.map( ( title: string, i: number ) => (
<div
className="ai-title-generation-options"
role="radiogroup"
aria-label={ __( 'Generated title options', 'ai' ) }
>
{ titles.map( ( title: string, i: number ) => (
<TitleOption
key={ `title-${ i }` }
title={ title }
index={ i }
isSelected={ selectedIndex === i }
isDisabled={ isDisabled }
onChange={ ( value: string ) => {
onTitleChange(
titlesToRender.map( ( item, index ) =>
index === i ? value : item
titles.map( ( item, idx ) =>
idx === i ? value : item
)
);
} }
onSelect={ onSelect }
onSelect={ () => onSelect( i ) }
/>
) ) }
</Flex>
</div>
);
}

Expand Down Expand Up @@ -153,7 +185,8 @@ async function generateTitles(
/**
* TitleToolbar component.
*
* Provides Generate/Re-generate button.
* Provides Generate/Re-generate button and a modal for selecting from
* AI-generated title suggestions.
*
* @return {JSX.Element} The toolbar component.
*/
Expand All @@ -165,13 +198,16 @@ export default function TitleToolbar(): JSX.Element | null {
const { editPost } = useDispatch( editorStore );

const [ isGenerating, setIsGenerating ] = useState< boolean >( false );
const [ isRegenerating, setIsRegenerating ] = useState< boolean >( false );
const [ isOpen, setOpen ] = useState< boolean >( false );
const [ titles, setTitles ] = useState< string[] >( [] );
const [ selectedIndex, setSelectedIndex ] = useState< number >( 0 );

const openModal = () => setOpen( true );
const closeModal = () => {
setOpen( false );
setTitles( [] );
setSelectedIndex( 0 );
};

const hasTitle = title.trim().length > 0;
Expand All @@ -180,7 +216,7 @@ export default function TitleToolbar(): JSX.Element | null {
: __( 'Generate', 'ai' );

/**
* Handles the generate/re-generate button click.
* Handles the toolbar Generate/Re-generate button click.
*/
const handleGenerate = async () => {
setIsGenerating( true );
Expand All @@ -194,6 +230,7 @@ export default function TitleToolbar(): JSX.Element | null {
content
);
setTitles( generatedTitles );
setSelectedIndex( 0 );
openModal();
} catch ( error: any ) {
( dispatch( noticesStore ) as any ).createErrorNotice( error, {
Expand All @@ -207,15 +244,40 @@ export default function TitleToolbar(): JSX.Element | null {
};

/**
* Handles selecting a title.
*
* @param {string} selectedTitle The selected title.
* Handles the Regenerate button inside the modal.
* Fetches a new batch of suggestions without closing the modal.
*/
const handleSelectTitle = async ( selectedTitle: string ) => {
editPost( {
title: selectedTitle,
} );
closeModal();
const handleRegenerate = async () => {
setIsRegenerating( true );
( dispatch( noticesStore ) as any ).removeNotice(
'ai_title_generation_error'
);

try {
const generatedTitles = await generateTitles(
postId as number,
content
);
setTitles( generatedTitles );
setSelectedIndex( 0 );
} catch ( error: any ) {
( dispatch( noticesStore ) as any ).createErrorNotice( error, {
id: 'ai_title_generation_error',
isDismissible: true,
} );
} finally {
setIsRegenerating( false );
}
};

/**
* Applies the selected title to the post and closes the modal.
*/
const handleInsert = () => {
if ( titles[ selectedIndex ] ) {
editPost( { title: titles[ selectedIndex ] } );
closeModal();
}
};

// Ensure the experiment is enabled.
Expand All @@ -240,7 +302,10 @@ export default function TitleToolbar(): JSX.Element | null {
</PostTypeSupportCheck>
{ isOpen && (
<Modal
title={ __( 'Select a title', 'ai' ) }
title={ __(
'Select a title or regenerate for more options',
'ai'
) }
onRequestClose={ closeModal }
isFullScreen={ false }
size="medium"
Expand All @@ -249,10 +314,41 @@ export default function TitleToolbar(): JSX.Element | null {
{ titles && (
<TitleOptionsList
titles={ titles }
selectedIndex={ selectedIndex }
isDisabled={ isRegenerating }
onTitleChange={ setTitles }
onSelect={ handleSelectTitle }
onSelect={ setSelectedIndex }
/>
) }
<Flex
justify="flex-end"
gap="3"
className="ai-title-generation-actions"
>
<FlexItem>
<Button
variant="secondary"
onClick={ handleRegenerate }
disabled={ isRegenerating }
isBusy={ isRegenerating }
>
{ isRegenerating
? __( 'Regenerating…', 'ai' )
: __( 'Regenerate', 'ai' ) }
</Button>
</FlexItem>
<FlexItem>
<Button
variant="primary"
onClick={ handleInsert }
disabled={
isRegenerating || ! titles[ selectedIndex ]
}
>
{ __( 'Insert', 'ai' ) }
</Button>
</FlexItem>
</Flex>
</Modal>
) }
</>
Expand Down
53 changes: 53 additions & 0 deletions src/experiments/title-generation/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
.ai-title-generation-modal {
.ai-title-generation-actions {
margin-top: 24px;
}

.ai-title-generation-options {
display: flex;
flex-direction: column;
gap: 8px;
}

.ai-title-generation-option {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 2px;
cursor: pointer;

&.is-selected:not(.is-disabled) {
background-color: color-mix(in srgb, var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)) 4%, transparent);
border-color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
}

&__radio {
margin-top: 3px;
flex-shrink: 0;
cursor: pointer;
}

.components-base-control {
flex: 1;
}

.components-textarea-control__input {
border: none;
box-shadow: none;
padding: 0;
background: transparent;
resize: none;
cursor: text;

&:focus {
box-shadow: none;
}
}

&.is-selected:not(.is-disabled) .components-textarea-control__input {
color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
}
}
}
1 change: 1 addition & 0 deletions src/experiments/title-generation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { registerPlugin } from '@wordpress/plugins';
/**
* Internal dependencies
*/
import './index.scss';
import TitleToolbar from './components/TitleToolbar';
import { TitleToolbarWrapper } from './components/TitleToolbarWrapper';

Expand Down
Loading
Loading