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/ContentGeneration.php b/includes/Classifai/Features/ContentGeneration.php
index 61e86297e..688ac6b0c 100644
--- a/includes/Classifai/Features/ContentGeneration.php
+++ b/includes/Classifai/Features/ContentGeneration.php
@@ -241,6 +241,8 @@ public function enqueue_editor_assets() {
get_asset_info( 'classifai-plugin-content-generation', 'version' ),
true
);
+
+ wp_enqueue_script( 'classifai-chat-ui-js' );
}
/**
diff --git a/includes/Classifai/Features/Feature.php b/includes/Classifai/Features/Feature.php
index fd265e7b1..46f61b6fb 100644
--- a/includes/Classifai/Features/Feature.php
+++ b/includes/Classifai/Features/Feature.php
@@ -70,6 +70,7 @@ public function setup() {
}
add_action( 'admin_enqueue_scripts', [ $this, 'register_plugin_area_script' ] );
+ add_action( 'admin_enqueue_scripts', [ $this, 'register_chat_ui_script' ], 10, 99 );
if ( $this->is_feature_enabled() ) {
$this->feature_setup();
@@ -157,6 +158,19 @@ public function register_plugin_area_script() {
);
}
+ /**
+ * Enqueues the JS asset required for the ChatUI plugin.
+ */
+ public function register_chat_ui_script() {
+ wp_register_script(
+ 'classifai-chat-ui-js',
+ CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-chat-ui.js',
+ get_asset_info( 'classifai-plugin-chat-ui', 'dependencies' ),
+ get_asset_info( 'classifai-plugin-chat-ui', 'version' ),
+ true
+ );
+ }
+
/**
* Set up the fields for each section.
*
diff --git a/includes/Classifai/Features/RewriteTone.php b/includes/Classifai/Features/RewriteTone.php
new file mode 100644
index 000000000..72a7d5d8e
--- /dev/null
+++ b/includes/Classifai/Features/RewriteTone.php
@@ -0,0 +1,252 @@
+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
+ );
+
+ wp_enqueue_script( 'classifai-chat-ui-js' );
+ }
+
+ /**
+ * 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 1b3e396fe..161572c80 100644
--- a/includes/Classifai/Providers/OpenAI/ChatGPT.php
+++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php
@@ -15,6 +15,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;
@@ -201,7 +202,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 );
@@ -225,6 +226,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;
@@ -874,6 +878,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 19b5a8d38..22819bedd 100644
--- a/includes/Classifai/Services/ServicesManager.php
+++ b/includes/Classifai/Services/ServicesManager.php
@@ -78,6 +78,7 @@ public function register_language_processing_features( array $features ): array
'\Classifai\Features\ExcerptGeneration',
'\Classifai\Features\ContentGeneration',
'\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..ce97cf049
--- /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, title } ) => {
+ 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/chat-ui/chat-ui.tsx b/src/js/components/chat-ui/chat-ui.tsx
new file mode 100644
index 000000000..6c0998b30
--- /dev/null
+++ b/src/js/components/chat-ui/chat-ui.tsx
@@ -0,0 +1,205 @@
+import React, {
+ useEffect,
+ useState,
+ useRef,
+ useLayoutEffect,
+ CSSProperties,
+} from 'react';
+import { motion, AnimatePresence } from 'motion/react';
+import { __ } from '@wordpress/i18n';
+import { SlotFillProvider, Slot, TabPanel } from '@wordpress/components';
+import { PluginArea } from '@wordpress/plugins';
+import { applyFilters } from '@wordpress/hooks';
+
+// Import our custom components
+import { SparkleIcon } from './sparkle-icon';
+
+// Define style objects outside of JSX
+const chatContainerStyles: CSSProperties = {
+ position: 'absolute',
+ bottom: '20px',
+ right: '20px',
+ zIndex: '1000',
+};
+
+const chatUIStyles: CSSProperties = {
+ width: '500px',
+ maxHeight: '700px',
+ backgroundColor: 'white',
+ boxShadow:
+ '0px 2px 3px 0px rgba(0, 0, 0, 0.05), 0px 4px 5px 0px rgba(0, 0, 0, 0.04), 0px 4px 5px 0px rgba(0, 0, 0, 0.03), 0px 16px 16px 0px rgba(0, 0, 0, 0.02)',
+ borderRadius: '8px',
+ border: '1px solid #e0e0e0',
+ display: 'flex',
+ flexDirection: 'column',
+};
+
+const chatContentStyles: CSSProperties = {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ maxHeight: '700px',
+ overflow: 'clip',
+ padding: '2px',
+};
+
+const chatButtonStyles: CSSProperties = {
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
+ padding: '0',
+ width: '48px',
+ height: '48px',
+ borderRadius: '999px',
+ minWidth: 'unset',
+ minHeight: 'unset',
+ color: 'white',
+ border: 'none',
+ cursor: 'pointer',
+ backgroundColor:
+ 'var(--wp-components-color-accent-darker-10,var(--wp-admin-theme-color-darker-10,#2145e6))',
+};
+
+const featureTabs = applyFilters( 'classifai.chatUI', [] ) as { name: string; title: string }[];
+
+/**
+ * ChatUI component
+ *
+ * Main component for the ClassifAI chat interface
+ *
+ * @return {React.ReactElement} The complete chat UI
+ */
+export const ChatUI: React.FC = () => {
+ const [ isExpanded, setIsExpanded ] = useState< boolean >( false );
+ const chatContainerRef = useRef< HTMLDivElement >( null );
+ const textareaRef = useRef< HTMLTextAreaElement >( null );
+
+ // Function to handle clicks outside the chat UI
+ const handleClickOutside = ( event: MouseEvent ): void => {
+ if (
+ chatContainerRef.current &&
+ ! chatContainerRef.current.contains( event.target as Node )
+ ) {
+ setIsExpanded( false );
+ }
+ };
+
+ useLayoutEffect( () => {
+ if ( textareaRef.current && isExpanded ) {
+ textareaRef.current.focus();
+ }
+ }, [ isExpanded ] );
+
+ // Add event listeners for clicks outside
+ useEffect( () => {
+ // Only add event listeners when the chat UI is expanded
+ if ( isExpanded ) {
+ // Add event listener to main document
+ document.addEventListener( 'mousedown', handleClickOutside );
+
+ // Add event listeners to all iframes
+ const iframes = document.querySelectorAll( 'iframe' );
+ iframes.forEach( ( iframe ) => {
+ try {
+ if ( iframe.contentDocument ) {
+ iframe.contentDocument.addEventListener(
+ 'mousedown',
+ handleClickOutside
+ );
+ }
+ } catch ( e ) {
+ // Cross-origin iframe access error - can't add listener
+ // Silently fail for cross-origin iframes
+ }
+ } );
+ }
+
+ return () => {
+ // Remove event listener from main document
+ document.removeEventListener( 'mousedown', handleClickOutside );
+
+ // Remove event listeners from all iframes
+ const iframes = document.querySelectorAll( 'iframe' );
+ iframes.forEach( ( iframe ) => {
+ try {
+ if ( iframe.contentDocument ) {
+ iframe.contentDocument.removeEventListener(
+ 'mousedown',
+ handleClickOutside
+ );
+ }
+ } catch ( e ) {
+ // Cross-origin iframe access error - can't remove listener
+ // Silently fail for cross-origin iframes
+ }
+ } );
+ };
+ }, [ isExpanded ] );
+
+ const toggleChatUI = (): void => {
+ setIsExpanded( ! isExpanded );
+ };
+
+ return (
+
+
+ { isExpanded ? (
+
+
+
+
+
+ { ( tab ) => (
+
+
+
+ ) }
+
+
+
+
+ ) : (
+
+
+
+ ) }
+
+
+ );
+};
diff --git a/src/js/components/chat-ui/index.js b/src/js/components/chat-ui/index.js
new file mode 100644
index 000000000..c935b6ad3
--- /dev/null
+++ b/src/js/components/chat-ui/index.js
@@ -0,0 +1,50 @@
+import React, { useEffect } from 'react';
+import { createRoot } from '@wordpress/element';
+import domReady from '@wordpress/dom-ready';
+import { registerPlugin } from '@wordpress/plugins';
+import { ChatUI } from './chat-ui';
+
+/**
+ * RenderChatUI component
+ *
+ * Renders the ChatUI component into the editor
+ *
+ * @return {React.ReactElement|null} Component that renders nothing directly
+ */
+export const RenderChatUI = () => {
+ useEffect( () => {
+ const editorIframe = document.querySelector(
+ '.editor-visual-editor.is-iframed'
+ );
+
+ if ( ! editorIframe || ! editorIframe.parentNode ) {
+ return;
+ }
+
+ const rootElement = document.createElement( 'div' );
+ editorIframe.parentNode.appendChild( rootElement );
+
+ if ( editorIframe?.parentElement?.querySelector( '.classifai-chat-container' ) ) {
+ return;
+ }
+
+ const root = createRoot( rootElement );
+ root.render( );
+
+ return () => {
+ root.unmount();
+ if ( editorIframe.parentNode && rootElement.parentNode ) {
+ editorIframe.parentNode.removeChild( rootElement );
+ }
+ };
+ }, [] );
+
+ return null;
+};
+
+// Initialize the plugin when the DOM is ready
+domReady( () => {
+ registerPlugin( 'classifai-chat-ui-plugin', {
+ render: RenderChatUI,
+ } );
+} );
diff --git a/src/js/features/content-generation/components/sparkle-icon.tsx b/src/js/components/chat-ui/sparkle-icon.tsx
similarity index 100%
rename from src/js/features/content-generation/components/sparkle-icon.tsx
rename to src/js/components/chat-ui/sparkle-icon.tsx
diff --git a/src/js/components/chat-ui/types.ts b/src/js/components/chat-ui/types.ts
new file mode 100644
index 000000000..614ddcda5
--- /dev/null
+++ b/src/js/components/chat-ui/types.ts
@@ -0,0 +1,4 @@
+export interface ConversationEntry {
+ prompt: string;
+ completion: string | null;
+}
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/content-generation/components/chat-ui.tsx b/src/js/features/content-generation/components/chat-ui.tsx
index eb5dc4564..cca93c68a 100644
--- a/src/js/features/content-generation/components/chat-ui.tsx
+++ b/src/js/features/content-generation/components/chat-ui.tsx
@@ -5,76 +5,29 @@ import React, {
useLayoutEffect,
CSSProperties,
} from 'react';
-import { motion, AnimatePresence } from 'motion/react';
import apiFetch from '@wordpress/api-fetch';
import { select, dispatch } from '@wordpress/data';
import { pasteHandler, parse } from '@wordpress/blocks';
import { store as editorStore } from '@wordpress/editor';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
+import { Fill } from '@wordpress/components';
import { decodeEntities } from '@wordpress/html-entities';
+import { addFilter } from '@wordpress/hooks';
// Import our custom components
-import { SparkleIcon } from './sparkle-icon';
import { ChatHistory } from './chat-history';
-import { QuickActionOptions } from './quick-action-options';
import { ErrorMessage } from './error-message';
import { ChatInput } from './chat-input';
import { ConversationEntry } from './types';
-// Define style objects outside of JSX
-const chatContainerStyles: CSSProperties = {
- position: 'absolute',
- bottom: '20px',
- right: '20px',
- zIndex: '1000',
-};
-
-const chatUIStyles: CSSProperties = {
- width: '500px',
- maxHeight: '700px',
- backgroundColor: 'white',
- padding: '14px',
- boxShadow:
- '0px 2px 3px 0px rgba(0, 0, 0, 0.05), 0px 4px 5px 0px rgba(0, 0, 0, 0.04), 0px 4px 5px 0px rgba(0, 0, 0, 0.03), 0px 16px 16px 0px rgba(0, 0, 0, 0.02)',
- borderRadius: '8px',
- border: '1px solid #e0e0e0',
- display: 'flex',
- flexDirection: 'column',
-};
-
-const chatContentStyles: CSSProperties = {
- display: 'flex',
- flexDirection: 'column',
- height: '100%',
- maxHeight: '700px',
- overflow: 'clip',
- padding: '2px',
-};
-
const chatTitleStyles: CSSProperties = {
marginBottom: '12px',
fontWeight: 'bold',
fontSize: '16px',
};
-const chatButtonStyles: CSSProperties = {
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
- padding: '0',
- width: '48px',
- height: '48px',
- borderRadius: '999px',
- minWidth: 'unset',
- minHeight: 'unset',
- color: 'white',
- border: 'none',
- cursor: 'pointer',
- backgroundColor:
- 'var(--wp-components-color-accent-darker-10,var(--wp-admin-theme-color-darker-10,#2145e6))',
-};
+const chatTabSlug = 'classifai-content-generation';
/**
* ChatUI component
@@ -321,10 +274,6 @@ export const ChatUI: React.FC = () => {
// Shift+Enter will add a new line by default (no action needed)
};
- const toggleChatUI = (): void => {
- setIsExpanded( ! isExpanded );
- };
-
const startOver = (): void => {
setConversation( [] );
setError( false );
@@ -382,77 +331,39 @@ export const ChatUI: React.FC = () => {
};
return (
-
-
- { isExpanded ? (
-
-
-
- { __( 'Generate content', 'classifai' ) }
-
-
-
-
- ) : (
-
-
-
+
+
-
+
+
+ setInputValue( value )
+ }
+ onKeyDown={ handleKeyDown }
+ isLoading={ isLoading }
+ placeholderText={ getPlaceholderText() }
+ />
+
+
);
};
+
+addFilter(
+ 'classifai.chatUI',
+ 'classifai',
+ ( args ) => {
+ args.push( {
+ name: chatTabSlug,
+ title: __( 'Generate Content', 'classifai' ),
+ } );
+ return args;
+ }
+);
diff --git a/src/js/features/content-generation/components/quick-action-options.tsx b/src/js/features/content-generation/components/quick-action-options.tsx
deleted file mode 100644
index fac64f90b..000000000
--- a/src/js/features/content-generation/components/quick-action-options.tsx
+++ /dev/null
@@ -1,231 +0,0 @@
-import React, { useState, CSSProperties } from 'react';
-import { Button, Icon } from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
-import { motion, AnimatePresence } from 'motion/react';
-import {
- search,
- update,
- paragraph,
- grid,
- table,
- formatListBullets,
-} from '@wordpress/icons';
-
-// Define style objects outside of JSX
-const containerStyles: CSSProperties = {
- marginBottom: '8px',
-};
-
-const optionsContainerStyles: CSSProperties = {
- display: 'flex',
- gap: '8px',
-};
-
-const actionButtonStyles: CSSProperties = {
- flex: 1,
- justifyContent: 'center',
- border: '1px solid #e0e0e0',
- padding: '12px 8px',
- borderRadius: '4px',
- backgroundColor: '#f9f9f9',
-};
-
-const iconTextStyles: CSSProperties = {
- marginLeft: '6px',
-};
-
-const toneButtonContainerStyles: CSSProperties = {
- marginBottom: '8px',
- marginTop: '12px',
-};
-
-const toneButtonStyles: CSSProperties = {
- width: '100%',
- justifyContent: 'flex-start',
- textAlign: 'left',
- marginBottom: '4px',
- padding: '8px 12px',
- border: 'none',
-};
-
-const actionButtonTextStyles: CSSProperties = {
- marginLeft: '8px',
-};
-
-const actionSectionStyles: CSSProperties = {
- borderTop: '1px solid #e0e0e0',
- paddingTop: '8px',
-};
-
-const actionItemButtonStyles: CSSProperties = {
- width: '100%',
- justifyContent: 'flex-start',
- textAlign: 'left',
- padding: '8px 12px',
- border: 'none',
-};
-
-/**
- * Props for the QuickActionOptions component
- */
-export interface QuickActionOptionsProps {
- onOptionSelect: ( option: string ) => void;
- hasContent: boolean;
-}
-
-/**
- * QuickActionOptions component
- *
- * Displays quick action buttons for common AI tasks
- *
- * @param {QuickActionOptionsProps} props Component props
- * @return {React.ReactElement} Quick action options UI
- */
-export const QuickActionOptions: React.FC< QuickActionOptionsProps > = ( {
- onOptionSelect,
- hasContent = false,
-} ) => {
- const [ showFullOptions, setShowFullOptions ] =
- useState< boolean >( false );
-
- return (
-
-
-
- { !! hasContent && (
- <>
-
-
-
- >
- ) }
-
-
- { showFullOptions && (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) }
-
-
- );
-};
diff --git a/src/js/features/content-generation/index.js b/src/js/features/content-generation/index.js
index 7d77f9cfc..a73c84494 100644
--- a/src/js/features/content-generation/index.js
+++ b/src/js/features/content-generation/index.js
@@ -1,46 +1,10 @@
-import React, { useEffect } from 'react';
-import { createRoot } from '@wordpress/element';
import domReady from '@wordpress/dom-ready';
import { registerPlugin } from '@wordpress/plugins';
import { ChatUI } from './components/chat-ui';
-/**
- * RenderChatUI component
- *
- * Renders the ChatUI component into the editor
- *
- * @return {React.ReactElement|null} Component that renders nothing directly
- */
-export const RenderChatUI = () => {
- useEffect( () => {
- const editorIframe = document.querySelector(
- '.editor-visual-editor.is-iframed'
- );
-
- if ( ! editorIframe || ! editorIframe.parentNode ) {
- return;
- }
-
- const rootElement = document.createElement( 'div' );
- editorIframe.parentNode.appendChild( rootElement );
-
- const root = createRoot( rootElement );
- root.render( );
-
- return () => {
- root.unmount();
- if ( editorIframe.parentNode && rootElement.parentNode ) {
- editorIframe.parentNode.removeChild( rootElement );
- }
- };
- }, [] );
-
- return null;
-};
-
// Initialize the plugin when the DOM is ready
domReady( () => {
- registerPlugin( 'classifai', {
- render: RenderChatUI,
+ registerPlugin( 'classifai-content-generation', {
+ render: ChatUI,
} );
} );
diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js
new file mode 100644
index 000000000..1227f04d0
--- /dev/null
+++ b/src/js/features/rewrite-tone/index.js
@@ -0,0 +1,403 @@
+/* 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,
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
+ Fill
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { addFilter } from '@wordpress/hooks';
+
+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',
+];
+
+const chatTabSlug = 'classifai-rewrite-tone';
+
+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 );
+ } }
+ >
+ { tones.audience.value.map(
+ ( { value, label }, index ) => (
+
+ )
+ ) }
+
+
+
+
+
+ { isPreviewVisible && (
+ setIsPreviewVisible( false ) }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ ) }
+ >
+ );
+};
+
+registerPlugin( 'classifai-rewrite-tone-plugin', {
+ render: RewriteTonePlugin,
+} );
+
+addFilter(
+ 'classifai.chatUI',
+ 'classifai',
+ ( args ) => {
+ args.push( {
+ name: chatTabSlug,
+ title: __( 'Rewrite Tone', 'classifai' ),
+ } );
+ return args;
+ }
+);
diff --git a/src/js/features/rewrite-tone/tones.js b/src/js/features/rewrite-tone/tones.js
new file mode 100644
index 000000000..d6a09c5a9
--- /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', 'classifai' ),
+ description: __(
+ 'Balanced, universally understandable.',
+ 'classifai'
+ ),
+ },
+ {
+ value: 'maketing',
+ label: __( 'Marketing', 'classifai' ),
+ description: __( 'Promotional, action-driven.', 'classifai' ),
+ },
+ {
+ value: 'professional',
+ label: __( '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 3adabecd3..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/editor';
-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..ca0dd9e2c
--- /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, allowedBlocks )
+ : [] ),
+ ],
+ []
+ );
+
+/**
+ * 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 69f5f8edd..4bdb55e2e 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -29,11 +29,13 @@ 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',
'classifai-plugin-classic-excerpt-generation': './src/js/features/excerpt-generation/classic/index.js',
'classifai-plugin-content-generation': './src/js/features/content-generation/index.js',
+ 'classifai-plugin-chat-ui': './src/js/components/chat-ui/index.js',
'classifai-plugin-inserter-media-category': './src/js/features/image-generation/inserter-media-category.js',
'classifai-plugin-image-generation-media-modal': [
'./src/js/features/image-generation/media-modal/index.js',