diff --git a/.eslintrc.json b/.eslintrc.json
index 4ca16399d..f88862bb7 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -23,7 +23,9 @@
"React": "readonly",
"Block": "readonly",
"classifai_term_cleanup_params": "readonly",
- "classifAISettings": "readonly"
+ "classifAISettings": "readonly",
+ "DOMParser": "readonly",
+ "MutationObserver": "readonly"
},
"rules": {
"react/jsx-no-undef": "off"
diff --git a/includes/Classifai/Features/RewriteTone.php b/includes/Classifai/Features/RewriteTone.php
new file mode 100644
index 000000000..b99add5b1
--- /dev/null
+++ b/includes/Classifai/Features/RewriteTone.php
@@ -0,0 +1,250 @@
+label = __( 'Rewrite Tone', 'classifai' );
+
+ // Contains all providers that are registered to the service.
+ $this->provider_instances = $this->get_provider_instances( LanguageProcessing::get_service_providers() );
+
+ // Contains just the providers this feature supports.
+ $this->supported_providers = [
+ ChatGPT::ID => __( 'OpenAI ChatGPT', 'classifai' ),
+ ];
+ }
+
+ /**
+ * Set up necessary hooks.
+ *
+ * We utilize this so we can register the REST route.
+ */
+ public function setup() {
+ parent::setup();
+ add_action( 'rest_api_init', [ $this, 'register_endpoints' ] );
+ }
+
+ /**
+ * Set up necessary hooks.
+ */
+ public function feature_setup() {
+ add_action( 'enqueue_block_assets', [ $this, 'enqueue_editor_assets' ] );
+ }
+
+ /**
+ * Register any needed endpoints.
+ */
+ public function register_endpoints() {
+ register_rest_route(
+ 'classifai/v1',
+ 'rewrite-tone',
+ [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [ $this, 'rest_endpoint_callback' ],
+ 'permission_callback' => '__return_true',
+ 'args' => [
+ 'id' => [
+ 'required' => true,
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ 'description' => esc_html__( 'Post ID to resize the content for.', 'classifai' ),
+ ],
+ 'content' => [
+ 'type' => 'array',
+ 'sanitize_callback' => function ( $content_array ) {
+ if ( is_array( $content_array ) ) {
+ return array_map(
+ function ( $item ) {
+ $item['clientId'] = sanitize_text_field( $item['clientId'] );
+ $item['content'] = wp_kses_post( $item['content'] );
+ return $item;
+ },
+ $content_array
+ );
+ }
+
+ return [];
+ },
+ 'validate_callback' => function ( $content_array ) {
+ if ( is_array( $content_array ) ) {
+ foreach ( $content_array as $item ) {
+ if ( ! isset( $item['clientId'] ) || ! is_string( $item['clientId'] ) ) {
+ return new WP_Error( 'rewrite_tone_invalid_client_id', __( 'Each item must have a valid clientId string.', 'classifai' ), [ 'status' => 400 ] );
+ }
+
+ if ( ! isset( $item['content'] ) || ! is_string( $item['content'] ) ) {
+ return new WP_Error( 'rewrite_tone_invalid_content', __( 'Each item must have valid content as a string.', 'classifai' ), [ 'status' => 400 ] );
+ }
+ }
+ return true;
+ }
+ return new WP_Error( 'rewrite_tone_invalid_data_format', __( 'Content must be an array of objects.', 'classifai' ), [ 'status' => 400 ] );
+ },
+ 'description' => esc_html__( 'The content to resize.', 'classifai' ),
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Check if a given request has access to resize content.
+ *
+ * @param WP_REST_Request $request Full data about the request.
+ * @return WP_Error|bool
+ */
+ public function resize_content_permissions_check( WP_REST_Request $request ) {
+ $post_id = $request->get_param( 'id' );
+
+ // Ensure we have a logged in user that can edit the item.
+ if ( empty( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
+ return false;
+ }
+
+ $post_type = get_post_type( $post_id );
+ $post_type_obj = get_post_type_object( $post_type );
+
+ // Ensure the post type is allowed in REST endpoints.
+ if ( ! $post_type || empty( $post_type_obj ) || empty( $post_type_obj->show_in_rest ) ) {
+ return false;
+ }
+
+ // Ensure the feature is enabled. Also runs a user check.
+ if ( ! $this->is_feature_enabled() ) {
+ return new WP_Error( 'not_enabled', esc_html__( 'Rewrite Tone is not currently enabled.', 'classifai' ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * Generic request handler for all our custom routes.
+ *
+ * @param WP_REST_Request $request The full request object.
+ * @return \WP_REST_Response
+ */
+ public function rest_endpoint_callback( WP_REST_Request $request ) {
+ $route = $request->get_route();
+
+ if ( strpos( $route, '/classifai/v1/rewrite-tone' ) === 0 ) {
+ return rest_ensure_response(
+ $this->run(
+ $request->get_param( 'id' ),
+ 'rewrite_tone',
+ [
+ 'content' => $request->get_param( 'content' ),
+ 'emotion' => $request->get_param( 'emotion' ),
+ 'formality' => $request->get_param( 'formality' ),
+ 'intent' => $request->get_param( 'intent' ),
+ 'audience' => $request->get_param( 'audience' ),
+ ]
+ )
+ );
+ }
+
+ return parent::rest_endpoint_callback( $request );
+ }
+
+ /**
+ * Enqueue the editor scripts.
+ */
+ public function enqueue_editor_assets() {
+ global $post;
+
+ if ( empty( $post ) || ! is_admin() ) {
+ return;
+ }
+
+ wp_enqueue_script(
+ 'classifai-plugin-rewrite-tone-js',
+ CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-rewrite-tone.js',
+ array_merge(
+ get_asset_info( 'classifai-plugin-rewrite-tone', 'dependencies' ),
+ array( Feature::PLUGIN_AREA_SCRIPT )
+ ),
+ get_asset_info( 'classifai-plugin-rewrite-tone', 'version' ),
+ true
+ );
+ }
+
+ /**
+ * Get the description for the enable field.
+ *
+ * @return string
+ */
+ public function get_enable_description(): string {
+ return esc_html__( '"Condense this text" and "Expand this text" menu items will be added to the paragraph block\'s toolbar menu.', 'classifai' );
+ }
+
+ /**
+ * Add any needed custom fields.
+ */
+ public function add_custom_settings_fields() {
+ $settings = $this->get_settings();
+
+ add_settings_field(
+ 'rewrite_tone_prompt',
+ esc_html__( 'Prompt', 'classifai' ),
+ [ $this, 'render_prompt_repeater_field' ],
+ $this->get_option_name(),
+ $this->get_option_name() . '_section',
+ [
+ 'label_for' => 'rewrite_tone_prompt',
+ 'default_value' => $settings['rewrite_tone_prompt'],
+ 'description' => esc_html__( 'Add a custom prompt, if desired.', 'classifai' ),
+ ]
+ );
+ }
+
+ /**
+ * Returns the default settings for the feature.
+ *
+ * @return array
+ */
+ public function get_feature_default_settings(): array {
+ return [
+ 'rewrite_tone_prompt' => [
+ [
+ 'title' => esc_html__( 'ClassifAI default', 'classifai' ),
+ 'original' => 1,
+ ],
+ ],
+ 'provider' => ChatGPT::ID,
+ ];
+ }
+
+ /**
+ * Sanitizes the default feature settings.
+ *
+ * @param array $new_settings Settings being saved.
+ * @return array
+ */
+ public function sanitize_default_feature_settings( array $new_settings ): array {
+ $new_settings['rewrite_tone_prompt'] = sanitize_prompts( 'rewrite_tone_prompt', $new_settings );
+
+ return $new_settings;
+ }
+}
diff --git a/includes/Classifai/Providers/OpenAI/ChatGPT.php b/includes/Classifai/Providers/OpenAI/ChatGPT.php
index 786a0b222..bf73923b2 100644
--- a/includes/Classifai/Providers/OpenAI/ChatGPT.php
+++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php
@@ -14,6 +14,7 @@
use Classifai\Features\KeyTakeaways;
use Classifai\Providers\Provider;
use Classifai\Normalizer;
+use Classifai\Features\RewriteTone;
use WP_Error;
use function Classifai\get_default_prompt;
@@ -200,7 +201,7 @@ public function sanitize_api_key( array $new_settings ): string {
*/
public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '', array $args = [] ) {
if ( ! $post_id || ! get_post( $post_id ) ) {
- return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required to generate an excerpt.', 'classifai' ) );
+ return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required.', 'classifai' ) );
}
$route_to_call = strtolower( $route_to_call );
@@ -224,6 +225,9 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = ''
case 'resize_content':
$return = $this->resize_content( $post_id, $args );
break;
+ case 'rewrite_tone':
+ $return = $this->rewrite_tone( $post_id, $args );
+ break;
case 'key_takeaways':
$return = $this->generate_key_takeaways( $post_id, $args );
break;
@@ -870,6 +874,75 @@ public function resize_content( int $post_id, array $args = array() ) {
return $return;
}
+ /**
+ * Rewrite the tone of the content.
+ *
+ * @param int $post_id The Post Id we're processing
+ * @param array $args Arguments passed in.
+ */
+ public function rewrite_tone( int $post_id, array $args = [] ) {
+ $feature = new RewriteTone();
+ $settings = $feature->get_settings();
+ $request = new APIRequest( $settings[ static::ID ]['api_key'] ?? '', $feature->get_option_name() );
+
+ $emotion = sanitize_text_field( $args['emotion'] ?? 'happy' );
+ $formality = sanitize_text_field( $args['formality'] ?? 'formal' );
+ $intent = sanitize_text_field( $args['intent'] ?? 'storytelling' );
+ $audience = sanitize_text_field( $args['audience'] ?? 'general' );
+ $prompt = "Rewrite the following content with the following tone attributes: Emotion: {$emotion}, Formality: {$formality}, Intent: {$intent}, Audience: {$audience}. Ensure that the rewritten content aligns with the selected attributes. Keep it {$formality} and make it {$intent}. The tone should reflect a {$emotion} sentiment while ensuring it resonates well with a {$audience} audience.";
+
+ $body = apply_filters(
+ 'classifai_chatgpt_resize_content_request_body',
+ [
+ 'model' => $this->chatgpt_model,
+ 'messages' => [
+ [
+ 'role' => 'system',
+ 'content' => $prompt,
+ ],
+ [
+ 'role' => 'system',
+ 'content' => 'Rewrite the above content while maintaining its original meaning but transforming it according to the specified tone attributes.',
+ ],
+ [
+ 'role' => 'system',
+ 'content' => "Please return each modified content with its corresponding 'clientId'. This is extremely important.",
+ ],
+ [
+ 'role' => 'system',
+ 'content' => 'The inline styles and HTML attributes should be preserved in the response.',
+ ],
+ [
+ 'role' => 'system',
+ 'content' => 'The HTML in the input should be preserved in the response.',
+ ],
+ [
+ 'role' => 'user',
+ 'content' => wp_json_encode( $args['content'] ),
+ ],
+ ],
+ ],
+ );
+
+ $response = $request->post(
+ $this->chatgpt_url,
+ [
+ 'body' => wp_json_encode( $body ),
+ ]
+ );
+
+ $return = [];
+
+ foreach ( $response['choices'] as $choice ) {
+ if ( isset( $choice['message'], $choice['message']['content'] ) ) {
+ // ChatGPT often adds quotes to strings, so remove those as well as extra spaces.
+ $return[] = trim( $choice['message']['content'], ' "\'' );
+ }
+ }
+
+ return $return;
+ }
+
/**
* Generate key takeaways from content.
*
diff --git a/includes/Classifai/Services/ServicesManager.php b/includes/Classifai/Services/ServicesManager.php
index 93b577d46..c52f45c7b 100644
--- a/includes/Classifai/Services/ServicesManager.php
+++ b/includes/Classifai/Services/ServicesManager.php
@@ -77,6 +77,7 @@ public function register_language_processing_features( array $features ): array
'\Classifai\Features\TitleGeneration',
'\Classifai\Features\ExcerptGeneration',
'\Classifai\Features\ContentResizing',
+ '\Classifai\Features\RewriteTone',
'\Classifai\Features\KeyTakeaways',
'\Classifai\Features\TextToSpeech',
'\Classifai\Features\AudioTranscriptsGeneration',
diff --git a/src/js/components/Inject-iframe-styles.js b/src/js/components/Inject-iframe-styles.js
new file mode 100644
index 000000000..597074eca
--- /dev/null
+++ b/src/js/components/Inject-iframe-styles.js
@@ -0,0 +1,86 @@
+import { useEffect, useRef, createPortal, render } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+import { useEditorCanvas } from '../hooks';
+
+export const InjectIframeStyles = ( { children } ) => {
+ const iframeCanvas = useEditorCanvas();
+
+ // Reference to the iframe in which we show blocks for preview.
+ const iframeRef = useRef( null );
+
+ useEffect( () => {
+ if ( ! iframeCanvas || ! iframeRef.current ) {
+ return;
+ }
+
+ // Get the newly created iframe's document.
+ const iframeDocument =
+ iframeRef.current.contentDocument ||
+ iframeRef.current.contentWindow.document;
+
+ // Copy the styles from the existing iframe (editor canvas).
+ const editorIframeDocument =
+ iframeCanvas.contentDocument || iframeCanvas.contentWindow.document;
+ const iframeStyles = editorIframeDocument.querySelectorAll(
+ 'link[rel="stylesheet"], style'
+ );
+
+ // Append styles (external & internal) to the new iframe's body.
+ iframeStyles.forEach( ( style ) => {
+ if ( style.tagName === 'LINK' ) {
+ iframeDocument.head.appendChild( style.cloneNode( true ) );
+ } else if ( style.tagName === 'STYLE' ) {
+ const clonedStyle = document.createElement( 'style' );
+ clonedStyle.textContent = style.textContent;
+ iframeDocument.head.appendChild( clonedStyle );
+ }
+ } );
+
+ const intervalId = setInterval( () => {
+ if ( ! iframeDocument.body ) {
+ return;
+ }
+
+ iframeDocument.body.classList.add(
+ 'block-editor-iframe__body',
+ 'editor-styles-wrapper',
+ 'post-type-post',
+ 'admin-color-fresh',
+ 'wp-embed-responsive'
+ );
+ iframeDocument.body
+ .querySelector( '.is-root-container' )
+ .classList.add(
+ 'is-desktop-preview',
+ 'is-layout-constrained',
+ 'wp-block-post-content-is-layout-constrained',
+ 'has-global-padding',
+ 'alignfull',
+ 'wp-block-post-content',
+ 'block-editor-block-list__layout'
+ );
+
+ clearInterval( intervalId );
+ }, 100 );
+
+ // Use React Portal to render the children into the iframe container.
+ // TODO: Might need to replace with `createPortal` due to React 18.
+ const portal = createPortal( children, iframeDocument.body );
+ render( portal, iframeDocument.body );
+ }, [ iframeCanvas ] );
+
+ if ( ! iframeCanvas ) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/src/js/components/index.js b/src/js/components/index.js
index 2dff88b8f..93814baef 100644
--- a/src/js/components/index.js
+++ b/src/js/components/index.js
@@ -1,2 +1,3 @@
export * from './disable-feature-button';
export * from './user-selector';
+export * from './inject-iframe-styles';
diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js
new file mode 100644
index 000000000..1d0492353
--- /dev/null
+++ b/src/js/features/rewrite-tone/index.js
@@ -0,0 +1,380 @@
+/* eslint-disable @wordpress/no-unsafe-wp-apis */
+import { registerPlugin } from '@wordpress/plugins';
+import { useRef, useState, useEffect } from '@wordpress/element';
+import { useDispatch } from '@wordpress/data';
+import {
+ store as blockEditorStore,
+ BlockEditorProvider,
+ BlockList,
+} from '@wordpress/block-editor';
+import { store as editorStore } from '@wordpress/editor';
+import { createBlock } from '@wordpress/blocks';
+import {
+ Modal,
+ Button,
+ Panel,
+ PanelBody,
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
+ SelectControl,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+import { useSelectedBlocks } from '../../hooks';
+import { InjectIframeStyles } from '../../components';
+import { tones } from './tones';
+import {
+ filterAndFlattenAllowedBlocks,
+ getClientIdToBlockContentMapping,
+ stripOutermostTag,
+ replaceBlocksInSource,
+} from '../../utils';
+
+const apiUrl = `${ wpApiSettings.root }classifai/v1/rewrite-tone`;
+const allowedTextBlocks = [
+ 'core/paragraph',
+ 'core/heading',
+ 'core/list',
+ 'core/list-item',
+];
+
+function defaultToneAttribute( toneAttribute = '', value = null ) {
+ if ( ! toneAttribute ) {
+ return '';
+ }
+
+ if ( ! value ) {
+ return (
+ window.localStorage.getItem(
+ `classifai-tone-attribute-${ toneAttribute }`
+ ) || ''
+ );
+ }
+
+ window.localStorage.setItem(
+ `classifai-tone-attribute-${ toneAttribute }`,
+ value
+ );
+}
+
+const RewriteTonePlugin = () => {
+ // Holds a reference to the original, unmodified editor blocks.
+ const blocksBackup = useRef( null );
+
+ // Flag indicating if the previewer modal is open.
+ const [ isPreviewVisible, setIsPreviewVisible ] = useState( false );
+
+ // Flag indicating if a rewrite is in progress.
+ const [ isRewriteInProgress, setIsRewriteInProgress ] = useState( false );
+
+ // Stores ChatGPT response.
+ const [ response, setResponse ] = useState( null );
+
+ // Stores all the editor blocks (modified and unmodified) that are created for preview.
+ const [ blocksForPreview, setBlocksForPreview ] = useState( [] );
+
+ // Stores the subset of editor blocks that have undergone tone rewriting.
+ const [ modifiedBlocks, setModifiedBlocks ] = useState( [] );
+
+ // We use this to replace blocks if the user is happy with the result.
+ const { replaceBlock } = useDispatch( blockEditorStore );
+
+ // Selected blocks in the block editor.
+ const allSelectedBlocks = useSelectedBlocks();
+
+ // Tone attributes from local storage.
+ const __defaultEmotion = defaultToneAttribute( 'emotion' );
+ const __defaultFormality = defaultToneAttribute( 'formality' );
+ const __defaultIntent = defaultToneAttribute( 'intent' );
+ const __defaultAudience = defaultToneAttribute( 'audience' );
+
+ // Local states for tone attributes.
+ const [ emotion, setEmotion ] = useState( __defaultEmotion || 'happy' );
+ const [ formality, setFormality ] = useState(
+ __defaultFormality || 'formal'
+ );
+ const [ intent, setIntent ] = useState( __defaultIntent || 'storytelling' );
+ const [ audience, setAudience ] = useState(
+ __defaultAudience || 'general'
+ );
+
+ /**
+ * Performs rewrite when triggered by the user on Button click.
+ *
+ * @return {void}
+ */
+ async function rewriteTone() {
+ try {
+ // We backup the original blocks.
+ blocksBackup.current = wp.data
+ .select( blockEditorStore )
+ .getBlocks();
+
+ setIsPreviewVisible( false );
+ setIsRewriteInProgress( true );
+ setBlocksForPreview( [] );
+
+ const filteredBlocks = getClientIdToBlockContentMapping(
+ filterAndFlattenAllowedBlocks(
+ allSelectedBlocks,
+ allowedTextBlocks
+ )
+ );
+
+ let __response = await fetch( apiUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify( {
+ id: wp.data.select( editorStore ).getCurrentPostId(),
+ content: filteredBlocks,
+ emotion,
+ formality,
+ intent,
+ audience,
+ } ),
+ } );
+
+ setIsRewriteInProgress( false );
+
+ if ( ! __response.ok ) {
+ return;
+ }
+
+ __response = await __response.json();
+ setResponse( JSON.parse( __response ) );
+ } catch ( e ) {
+ setIsRewriteInProgress( false );
+ }
+ }
+
+ /**
+ * Applies the result to the editor canvas when the user
+ * accepts it.
+ */
+ const applyResult = () => {
+ modifiedBlocks.forEach( ( { clientId, blocks } ) => {
+ replaceBlock( clientId, blocks );
+ } );
+
+ setIsPreviewVisible( false );
+ };
+
+ useEffect(
+ function reactToResponse() {
+ if ( ! Array.isArray( response ) ) {
+ return;
+ }
+
+ const __modifiedBlocks = response.map(
+ ( { clientId, content } ) => {
+ // We get the same block clientID in the response.
+ // Get the block using the clientID from the block editor data store.
+ const currentBlock = wp.data
+ .select( blockEditorStore )
+ .getBlock( clientId );
+
+ // We apply the original block attributes to the current iterated block.
+ currentBlock.attributes = wp.data
+ .select( blockEditorStore )
+ .getBlockAttributes( clientId );
+
+ // This will automatically create a new block by detecting the HTML.
+ let newBlock = wp.blocks.rawHandler( { HTML: content } );
+
+ if (
+ Array.isArray( newBlock ) &&
+ 1 === newBlock.length &&
+ 'core/html' === newBlock[ 0 ].name
+ ) {
+ // If a List item block is selected (without selecting the List block), and
+ // sent in the request, the response also returns the HTML with ...content....
+ // Gutenberg does not recognise without , and hence rawHandler() returns a
+ // generic `core/html` block instead of a `core/list-item` block.
+ //
+ // We handle this separately by using `createBlock()` isntead.
+ newBlock = createBlock( currentBlock.name, {
+ // The response contains `` tags, which we remove here as they are added
+ // by `createBlock()`. If we don't do this, then nested List item blocks will be
+ // created.
+ content: stripOutermostTag( content ),
+ } );
+
+ return {
+ clientId,
+ blocks: [ newBlock ],
+ };
+ }
+
+ return {
+ clientId,
+ blocks: newBlock,
+ };
+ }
+ );
+
+ const __blocksForPreview = replaceBlocksInSource(
+ blocksBackup.current,
+ __modifiedBlocks
+ );
+
+ setBlocksForPreview( __blocksForPreview );
+ setModifiedBlocks( __modifiedBlocks );
+ setIsPreviewVisible( true );
+ },
+ [ response ]
+ );
+
+ return (
+ <>
+
+
+
+ {
+ setEmotion( newEmotion );
+ defaultToneAttribute( 'emotion', newEmotion );
+ } }
+ >
+ { tones.emotion.value.map(
+ ( { value, label }, index ) => (
+
+ )
+ ) }
+
+
+ el.value === formality
+ )?.description
+ }
+ onChange={ ( newFormality ) => {
+ setFormality( newFormality );
+ defaultToneAttribute(
+ 'formality',
+ newFormality
+ );
+ } }
+ >
+ { tones.formality.value.map(
+ ( { value, label }, index ) => (
+
+ )
+ ) }
+
+
+ el.value === intent
+ )?.description
+ }
+ onChange={ ( newIntent ) => {
+ setIntent( newIntent );
+ defaultToneAttribute( 'intent', newIntent );
+ } }
+ >
+ { tones.intent.value.map(
+ ( { value, label }, index ) => (
+
+ )
+ ) }
+
+
+ el.value === audience
+ )?.description
+ }
+ onChange={ ( newAudience ) => {
+ setAudience( newAudience );
+ defaultToneAttribute( 'audience', newAudience );
+ } }
+ />
+
+
+
+
+
+
+ { isPreviewVisible && (
+ setIsPreviewVisible( false ) }
+ >
+
+
+
+
+
+
+
+
+
+
+ ) }
+ >
+ );
+};
+
+registerPlugin( 'classifai-rewrite-tone-plugin', {
+ render: RewriteTonePlugin,
+} );
diff --git a/src/js/features/rewrite-tone/tones.js b/src/js/features/rewrite-tone/tones.js
new file mode 100644
index 000000000..93ef7d6ea
--- /dev/null
+++ b/src/js/features/rewrite-tone/tones.js
@@ -0,0 +1,92 @@
+import { __ } from '@wordpress/i18n';
+
+export const tones = {
+ emotion: {
+ label: __( 'Emotion', 'classifai' ),
+ value: [
+ {
+ value: 'happy',
+ label: __( 'Happy', 'classifai' ),
+ },
+ {
+ value: 'neutral',
+ label: __( 'Neutral', 'classifai' ),
+ },
+ {
+ value: 'sad',
+ label: __( 'Sad', 'classifai' ),
+ },
+ ],
+ },
+ formality: {
+ label: __( 'Formality', 'classifai' ),
+ value: [
+ {
+ value: 'formal',
+ label: __( 'Formal', 'classifai' ),
+ description: __(
+ 'Professional, structured, business-like.',
+ 'classifai'
+ ),
+ },
+ {
+ value: 'informal',
+ label: __( 'Informal', 'classifai' ),
+ description: __( 'Conversational, relaxed.', 'classifai' ),
+ },
+ {
+ value: 'supportive',
+ label: __( 'Supportive', 'classifai' ),
+ description: __( 'Reassuring, helpful.', 'classifai' ),
+ },
+ ],
+ },
+ intent: {
+ label: __( 'Intent', 'classifai' ),
+ value: [
+ {
+ value: 'dramatic',
+ label: __( 'Dramatic', 'classifai' ),
+ description: __( 'Intense, theatrical.', 'classifai' ),
+ },
+ {
+ value: 'persuasive',
+ label: __( 'Persuasive', 'classifai' ),
+ description: __( 'Convincing, compelling.', 'classifai' ),
+ },
+ {
+ value: 'storytelling',
+ label: __( 'Storytelling', 'classifai' ),
+ description: __( 'Engaging, immersive.', 'classifai' ),
+ },
+ ],
+ },
+ audience: {
+ label: __( 'Audience', 'classifai' ),
+ value: [
+ {
+ value: 'educational',
+ label: __( 'Educational', 'classifai' ),
+ description: __( 'Clear, instructive.', 'classifai' ),
+ },
+ {
+ value: 'general',
+ label: __( 'General Audience', 'classifai' ),
+ description: __(
+ 'Balanced, universally understandable.',
+ 'classifai'
+ ),
+ },
+ {
+ value: 'maketing',
+ label: __( 'Marketing & Sales', 'classifai' ),
+ description: __( 'Promotional, action-driven.', 'classifai' ),
+ },
+ {
+ value: 'professional',
+ label: __( 'Business & Professional', 'classifai' ),
+ description: __( 'Corporate, industry-focused.', 'classifai' ),
+ },
+ ],
+ },
+};
diff --git a/src/js/features/slot-fill/editor-header-plugin-area.js b/src/js/features/slot-fill/editor-header-plugin-area.js
new file mode 100644
index 000000000..13d642284
--- /dev/null
+++ b/src/js/features/slot-fill/editor-header-plugin-area.js
@@ -0,0 +1,91 @@
+/**
+ * External dependencies
+ */
+import { SlotFillProvider, Slot, Fill, Button } from '@wordpress/components';
+import { PluginArea } from '@wordpress/plugins';
+import { createRoot, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { ReactComponent as icon } from '../../../../assets/img/block-icon.svg';
+
+window._wpLoadBlockEditor.then( async () => {
+ let headerSettingsPanel = null;
+
+ await new Promise( ( resolve ) => {
+ const intervalId = setInterval( () => {
+ headerSettingsPanel = document.querySelector(
+ '.editor-header__settings'
+ );
+
+ if ( headerSettingsPanel ) {
+ clearInterval( intervalId );
+ resolve();
+ }
+ }, 500 );
+ } );
+
+ if ( ! headerSettingsPanel ) {
+ return;
+ }
+
+ const classifaiHeaderSettingButton = document.createElement( 'div' );
+ classifaiHeaderSettingButton.classList.add(
+ 'classifai-editor-header-setting-wrapper'
+ );
+ classifaiHeaderSettingButton.style.position = 'relative';
+ headerSettingsPanel.insertBefore(
+ classifaiHeaderSettingButton,
+ headerSettingsPanel.firstChild
+ );
+
+ const wrapperRoot = createRoot( classifaiHeaderSettingButton );
+ wrapperRoot.render( );
+} );
+
+function RenderClassifAIEditorHeaderPluginArea() {
+ const [ isOpen, setIsOpen ] = useState( false );
+
+ const popoverStyles = {
+ position: 'absolute',
+ top: 'calc(100% + 4px)',
+ right: 'calc(100% - 20px)',
+ boxShadow:
+ '0 4px 5px #0000000a,0 12px 12px #00000008,0 16px 16px #00000005',
+ borderRadius: '4px',
+ transform: 'scale(1)',
+ transition: 'transform 0.2s ease',
+ 'transform-origin': 'top right',
+ };
+
+ if ( ! isOpen ) {
+ popoverStyles.opacity = 0;
+ popoverStyles.pointerEvents = 'none';
+ popoverStyles.transform = 'scale(0)';
+ popoverStyles.transition = 'transform 0.2s ease';
+ popoverStyles.zIndex = -10;
+ }
+
+ return (
+
+ { /* ✋🏻🛑⛔️ Don't use the Gutenberg's Popover component here. When clicked outside, it unmounts and breaks any ongoing
+ ClassifAI related processes, since we're rendering inside the Popover here.
+ This is why we implemented a custom popover which hides in plan sight, instead of unmounting. */ }
+
+
+ );
+}
+
+window.ClassifaiEditorHeaderSettingPanel = ( { children } ) => {
+ return (
+ { children }
+ );
+};
diff --git a/src/js/features/slot-fill/editor-sidebar-plugin-area.js b/src/js/features/slot-fill/editor-sidebar-plugin-area.js
new file mode 100644
index 000000000..ed7451ad5
--- /dev/null
+++ b/src/js/features/slot-fill/editor-sidebar-plugin-area.js
@@ -0,0 +1,43 @@
+/**
+ * External dependencies
+ */
+import { PluginDocumentSettingPanel } from '@wordpress/edit-post';
+import { Icon, SlotFillProvider, Slot, Fill } from '@wordpress/components';
+import { PluginArea, registerPlugin } from '@wordpress/plugins';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { ReactComponent as icon } from '../../../../assets/img/block-icon.svg';
+
+const ClassifAIEditorSidebarPluginArea = () => {
+ return (
+
+ }
+ className="classifai-panel"
+ >
+
+
+
+
+
+ );
+};
+
+registerPlugin( 'classifai-editor-sidebar-plugin-area', {
+ render: ClassifAIEditorSidebarPluginArea,
+} );
+
+window.ClassifaiEditorSettingPanel = ( { children } ) => {
+ return (
+ { children }
+ );
+};
diff --git a/src/js/features/slot-fill/index.js b/src/js/features/slot-fill/index.js
index cc4819cb7..f2fc26961 100644
--- a/src/js/features/slot-fill/index.js
+++ b/src/js/features/slot-fill/index.js
@@ -1,41 +1,2 @@
-/**
- * External dependencies
- */
-import { PluginDocumentSettingPanel } from '@wordpress/edit-post';
-import { Icon, SlotFillProvider, Slot, Fill } from '@wordpress/components';
-import { PluginArea, registerPlugin } from '@wordpress/plugins';
-import { __ } from '@wordpress/i18n';
-
-/**
- * Internal dependencies
- */
-import { ReactComponent as icon } from '../../../../assets/img/block-icon.svg';
-
-const ClassifAIPluginArea = () => {
- return (
-
- }
- className="classifai-panel"
- >
-
-
-
-
-
- );
-};
-
-registerPlugin( 'classifai-plugin-area', {
- render: ClassifAIPluginArea,
-} );
-
-window.ClassifaiEditorSettingPanel = ( { children } ) => {
- return { children };
-};
+import './editor-sidebar-plugin-area';
+import './editor-header-plugin-area';
diff --git a/src/js/hooks/index.js b/src/js/hooks/index.js
new file mode 100644
index 000000000..7d90a2596
--- /dev/null
+++ b/src/js/hooks/index.js
@@ -0,0 +1,2 @@
+export * from './useSelectedBlocks';
+export * from './useEditorCanvas';
diff --git a/src/js/hooks/useEditorCanvas.js b/src/js/hooks/useEditorCanvas.js
new file mode 100644
index 000000000..f6260adc2
--- /dev/null
+++ b/src/js/hooks/useEditorCanvas.js
@@ -0,0 +1,46 @@
+import { useState, useEffect } from '@wordpress/element';
+
+export const useEditorCanvas = () => {
+ const [ iframeCanvas, setIframeCanvas ] = useState( null );
+
+ useEffect( () => {
+ let observer;
+
+ /**
+ * Function to check for the editor canvas iframe.
+ *
+ * @return {boolean} True if the iframe is found, false otherwise.
+ */
+ const checkForCanvas = () => {
+ const __iframeCanvas =
+ document.getElementsByName( 'editor-canvas' );
+ if ( __iframeCanvas.length > 0 ) {
+ setIframeCanvas( __iframeCanvas[ 0 ] );
+ return true;
+ }
+ return false;
+ };
+
+ // Perform an initial check in case the iframe already exists.
+ if ( ! checkForCanvas() ) {
+ // Set up the observer to listen for DOM mutations.
+ observer = new MutationObserver( () => {
+ if ( checkForCanvas() ) {
+ // Disconnect the observer once the iframe is found.
+ observer.disconnect();
+ }
+ } );
+
+ // Observe changes to the DOM body or its descendants.
+ observer.observe( document.body, {
+ childList: true,
+ subtree: true,
+ } );
+ }
+
+ // Cleanup observer on component unmount.
+ return () => observer?.disconnect();
+ }, [] );
+
+ return iframeCanvas;
+};
diff --git a/src/js/hooks/useSelectedBlocks.js b/src/js/hooks/useSelectedBlocks.js
new file mode 100644
index 000000000..9f058893f
--- /dev/null
+++ b/src/js/hooks/useSelectedBlocks.js
@@ -0,0 +1,16 @@
+import { useSelect } from '@wordpress/data';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+
+/**
+ * Returns array of block objects of the blocks that are selected.
+ *
+ * @return {Array} Array of block objects.
+ */
+export const useSelectedBlocks = () => {
+ return useSelect( ( select ) => {
+ const selectedBlock = select( blockEditorStore ).getSelectedBlock();
+ const multiSelectedBlocks =
+ select( blockEditorStore ).getMultiSelectedBlocks();
+ return selectedBlock ? [ selectedBlock ] : multiSelectedBlocks;
+ } );
+};
diff --git a/src/js/utils/index.js b/src/js/utils/index.js
new file mode 100644
index 000000000..c29419b32
--- /dev/null
+++ b/src/js/utils/index.js
@@ -0,0 +1,93 @@
+import { getBlockContent } from '@wordpress/blocks';
+
+export const filterAndFlattenAllowedBlocks = (
+ blocks = [],
+ allowedBlocks = []
+) =>
+ blocks.reduce(
+ ( acc, block ) => [
+ ...acc,
+ ...( allowedBlocks.includes( block.name ) ? [ block ] : [] ),
+ ...( block.innerBlocks
+ ? filterAndFlattenAllowedBlocks( block.innerBlocks )
+ : [] ),
+ ],
+ []
+ );
+
+/**
+ * Removes the delimiters from the content.
+ *
+ * @param {string} content The block content.
+ * @return {Array} Array of objects with content without delimiters.
+ */
+export const removeBlockDelimiters = ( content ) => {
+ return content.replace( //g, '' );
+};
+
+/**
+ * Retrieves the mapping of client IDs to block content.
+ *
+ * @param {Array} blocks
+ * @return {Object} An object where the keys are client IDs and the values are the corresponding block content.
+ */
+export const getClientIdToBlockContentMapping = ( blocks = [] ) =>
+ blocks.map( ( block ) => ( {
+ clientId: block.clientId,
+ content: removeBlockDelimiters( getBlockContent( block ) ),
+ } ) );
+
+/**
+ * Returns HTML string without the outermost tags.
+ *
+ * @param {string} htmlContent HTML as string.
+ * @return {string} HTML string without outermost tags stripped.
+ */
+export const stripOutermostTag = ( htmlContent = '' ) => {
+ // Parse the input HTML string into a DOM structure
+ const parser = new DOMParser();
+ const doc = parser.parseFromString( htmlContent, 'text/html' );
+
+ // Get the first element within the body (this is the outermost element)
+ const outermostElement = doc.body.firstElementChild;
+
+ // Return the innerHTML of the outermost element, which removes the outermost tag
+ return outermostElement ? outermostElement.innerHTML : htmlContent;
+};
+
+/**
+ * Replaces subset of blocks in the copy of the editor's original blocks with
+ * modified blocks and returns a new array.
+ *
+ * Suppose the editor originally has 6 blocks and blocks 3 & 4 have undergone tone
+ * rewriting which returns blocks 3' and 4'. This function returns 1-2-3'-4'-5-6.
+ *
+ * @param {Array} sourceBlocks Array of original, unmodified editor blocks.
+ * @param {Array} modifiedBlocks Subset of editor blocks which have undergone tone rewriting.
+ * @return {Array} Array of blocks that include original and modified blocks.
+ */
+export const replaceBlocksInSource = (
+ sourceBlocks = [],
+ modifiedBlocks = []
+) => {
+ const updateBlock = ( blocks ) => {
+ return blocks.map( ( block ) => {
+ const modified = modifiedBlocks.find(
+ ( modifiedBlock ) => modifiedBlock.clientId === block.clientId
+ );
+
+ if ( modified ) {
+ return modified.blocks[ 0 ];
+ }
+
+ return {
+ ...block,
+ innerBlocks: block.innerBlocks
+ ? updateBlock( block.innerBlocks )
+ : [],
+ };
+ } );
+ };
+
+ return updateBlock( sourceBlocks );
+};
diff --git a/webpack.config.js b/webpack.config.js
index b6b337104..192e20523 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -29,6 +29,7 @@ module.exports = {
'classifai-plugin-text-to-speech': './src/js/features/text-to-speech/index.js',
'classifai-plugin-text-to-speech-frontend': './src/js/features/text-to-speech/frontend/index.js',
'classifai-plugin-content-resizing': './src/js/features/content-resizing/index.js',
+ 'classifai-plugin-rewrite-tone': './src/js/features/rewrite-tone/index.js',
'classifai-plugin-title-generation': './src/js/features/title-generation/index.js',
'classifai-plugin-classic-title-generation': './src/js/features/title-generation/classic/index.js',
'classifai-plugin-excerpt-generation': './src/js/features/excerpt-generation/index.js',