From 4e6dbb9e7a8bc2d9bf7d6cd6c900e398b0bd4762 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 18:14:06 +0530 Subject: [PATCH 01/23] register Tone rewriting --- includes/Classifai/Features/RewriteTone.php | 255 ++++++++++++++++++ .../Classifai/Providers/OpenAI/ChatGPT.php | 80 +++++- .../Classifai/Services/ServicesManager.php | 1 + 3 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 includes/Classifai/Features/RewriteTone.php diff --git a/includes/Classifai/Features/RewriteTone.php b/includes/Classifai/Features/RewriteTone.php new file mode 100644 index 000000000..9cbc733a6 --- /dev/null +++ b/includes/Classifai/Features/RewriteTone.php @@ -0,0 +1,255 @@ +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' ), + ] + ) + ); + } + + 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', + 'placeholder' => $this->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' ), + 'prompt' => $this->prompt, + '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 7e126b11f..71724b551 100644 --- a/includes/Classifai/Providers/OpenAI/ChatGPT.php +++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php @@ -11,6 +11,7 @@ use Classifai\Features\TitleGeneration; use Classifai\Providers\Provider; use Classifai\Normalizer; +use Classifai\Features\RewriteTone; use WP_Error; use function Classifai\get_default_prompt; @@ -195,7 +196,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 ); @@ -215,6 +216,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; } return $return; @@ -663,6 +667,80 @@ 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() ); + $prompt = esc_textarea( get_default_prompt( $settings['rewrite_tone_prompt'] ) ?? $feature->prompt ); + + /** + * Filter the prompt we will send to ChatGPT. + * + * @since x.x.x + * @hook classifai_chatgpt_rewrite_tone_prompt + * + * @param {string} $prompt Prompt we are sending to ChatGPT. Gets added before post content. + * @param {int} $post_id ID of post we are summarizing. + * @param {array} $args Arguments passed to endpoint. + * + * @return {string} Prompt. + */ + $prompt = apply_filters( 'classifai_chatgpt_rewrite_tone_prompt', $prompt, $post_id, $args ); + + $body = apply_filters( + 'classifai_chatgpt_resize_content_request_body', + [ + 'model' => $this->chatgpt_model, + 'messages' => [ + [ + 'role' => 'system', + 'content' => $prompt, + ], + [ + 'role' => 'system', + 'content' => "Please return each modified content with its corresponding 'clientId'.", + ], + [ + '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; + } + /** * Get our content, trimming if needed. * diff --git a/includes/Classifai/Services/ServicesManager.php b/includes/Classifai/Services/ServicesManager.php index 9c76503ae..7dce55943 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\TextToSpeech', '\Classifai\Features\AudioTranscriptsGeneration', '\Classifai\Features\Moderation', From dbe1299d1a9b263019b566d9a8a50de83e861e82 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 18:14:26 +0530 Subject: [PATCH 02/23] setup webpack and eslintrc --- .eslintrc.json | 3 ++- webpack.config.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index 4ca16399d..e07d06f98 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,7 +23,8 @@ "React": "readonly", "Block": "readonly", "classifai_term_cleanup_params": "readonly", - "classifAISettings": "readonly" + "classifAISettings": "readonly", + "DOMParser": "readonly" }, "rules": { "react/jsx-no-undef": "off" diff --git a/webpack.config.js b/webpack.config.js index 98e9aa3b4..5de568f8b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -26,6 +26,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', From a901a54101ef1240819972161fd38cc7b22f9fde Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 18:14:58 +0530 Subject: [PATCH 03/23] add the useSelectedBlocks hook --- src/js/hooks/index.js | 1 + src/js/hooks/useSelectedBlocks.js | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/js/hooks/index.js create mode 100644 src/js/hooks/useSelectedBlocks.js diff --git a/src/js/hooks/index.js b/src/js/hooks/index.js new file mode 100644 index 000000000..4be75c9ac --- /dev/null +++ b/src/js/hooks/index.js @@ -0,0 +1 @@ +export { useSelectedBlocks } from './useSelectedBlocks'; \ No newline at end of file diff --git a/src/js/hooks/useSelectedBlocks.js b/src/js/hooks/useSelectedBlocks.js new file mode 100644 index 000000000..cc106d019 --- /dev/null +++ b/src/js/hooks/useSelectedBlocks.js @@ -0,0 +1,18 @@ +import { useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Returns array of block objects of the blocks that are selected. + * + * @returns {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; + } ); +}; From 749a2c786e06681949453431f86caee704e185e4 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 18:21:10 +0530 Subject: [PATCH 04/23] register classifai-rewrite-tone-plugin --- src/js/features/rewrite-tone/index.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/js/features/rewrite-tone/index.js diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js new file mode 100644 index 000000000..d71cb5565 --- /dev/null +++ b/src/js/features/rewrite-tone/index.js @@ -0,0 +1,17 @@ +import { registerPlugin } from '@wordpress/plugins'; +import { useSelectedBlocks } from '../../hooks'; + +const { ClassifaiEditorSettingPanel } = window; + +const RewriteTonePlugin = () => { + const allSelectedBlocks = useSelectedBlocks(); + + return ( + + + ); +}; + +registerPlugin( 'classifai-rewrite-tone-plugin', { + render: RewriteTonePlugin, +} ); From f453c28de5285868eb08568c90c987a065df38af Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 20:32:35 +0530 Subject: [PATCH 05/23] add the filterAndFlattenAllowedBlocks util --- src/js/features/rewrite-tone/index.js | 9 +++++++++ src/js/utils/index.js | 12 ++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/js/utils/index.js diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js index d71cb5565..c26ccd982 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -1,10 +1,19 @@ import { registerPlugin } from '@wordpress/plugins'; + import { useSelectedBlocks } from '../../hooks'; +import { filterAndFlattenAllowedBlocks } from '../../utils'; const { ClassifaiEditorSettingPanel } = window; +const allowedTextBlocks = [ + 'core/paragraph', + 'core/heading', + 'core/list', + 'core/list-item', +]; const RewriteTonePlugin = () => { const allSelectedBlocks = useSelectedBlocks(); + const filteredBlocks = filterAndFlattenAllowedBlocks( allSelectedBlocks, allowedTextBlocks ); return ( diff --git a/src/js/utils/index.js b/src/js/utils/index.js new file mode 100644 index 000000000..7d4e6c370 --- /dev/null +++ b/src/js/utils/index.js @@ -0,0 +1,12 @@ +export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) => blocks.reduce( + ( acc, block ) => [ + ...acc, + ...( allowedBlocks.includes( block.name ) + ? [ block ] + : [] ), + ...( block.innerBlocks + ? filterAndFlattenAllowedBlocks( block.innerBlocks ) + : [] ), + ], + [] +); From c865d7ad49b3b0140ab6bdccea8508f19be9cb66 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sat, 18 Jan 2025 18:30:49 +0530 Subject: [PATCH 06/23] add the useEditorCanvas hook --- src/js/hooks/index.js | 3 ++- src/js/hooks/useEditorCanvas.js | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/js/hooks/useEditorCanvas.js diff --git a/src/js/hooks/index.js b/src/js/hooks/index.js index 4be75c9ac..7d90a2596 100644 --- a/src/js/hooks/index.js +++ b/src/js/hooks/index.js @@ -1 +1,2 @@ -export { useSelectedBlocks } from './useSelectedBlocks'; \ No newline at end of file +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..ff6315d61 --- /dev/null +++ b/src/js/hooks/useEditorCanvas.js @@ -0,0 +1,45 @@ +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; +}; From 7475d59ba69e1dcfb2cf50da2e36ae5bc615c315 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sat, 18 Jan 2025 18:31:39 +0530 Subject: [PATCH 07/23] add the InjectIframeStyles component --- src/js/components/InjectIframeStyles.js | 85 +++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/js/components/InjectIframeStyles.js diff --git a/src/js/components/InjectIframeStyles.js b/src/js/components/InjectIframeStyles.js new file mode 100644 index 000000000..9c7fe704d --- /dev/null +++ b/src/js/components/InjectIframeStyles.js @@ -0,0 +1,85 @@ +import { useEffect, createPortal } from '@wordpress/element'; + +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 ( +
+ +
+ ); +}; From b2eb2943caaf5b0836dbbc898d75fa24386e0d76 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sat, 18 Jan 2025 18:32:06 +0530 Subject: [PATCH 08/23] add the getClientIdToBlockContentMapping util --- src/js/utils/index.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/js/utils/index.js b/src/js/utils/index.js index 7d4e6c370..7499998fb 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -1,3 +1,5 @@ +import { getBlockContent } from '@wordpress/blocks'; + export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) => blocks.reduce( ( acc, block ) => [ ...acc, @@ -10,3 +12,14 @@ export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) ], [] ); + +/** + * Retrieves the mapping of client IDs to block content. + * + * @param {Array} blocks + * @returns {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: getBlockContent( block ), +} ) ); From 217ee9a7ada5addd89cd6b259e8f73b35b3f35b8 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Wed, 26 Feb 2025 18:27:42 +0530 Subject: [PATCH 09/23] add the stripOutermostTag util --- src/js/utils/index.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/js/utils/index.js b/src/js/utils/index.js index 7499998fb..c3c551fdf 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -23,3 +23,21 @@ export const getClientIdToBlockContentMapping = ( blocks = [] ) => blocks.map( ( clientId: block.clientId, content: getBlockContent( block ), } ) ); + +/** + * Returns HTML string without the outermost tags. + * + * @param {string} htmlContent HTML as string. + * @return {string} HTML string without outermost tags stripped. + */ +export function 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; +} From de829aea03c71ad2519342ada870c316e1f45f8d Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Wed, 26 Feb 2025 18:28:18 +0530 Subject: [PATCH 10/23] implement the rewriteTone() method --- src/js/features/rewrite-tone/index.js | 72 +++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js index c26ccd982..55bce29cc 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -1,9 +1,16 @@ import { registerPlugin } from '@wordpress/plugins'; +import { useRef, useState } from '@wordpress/element'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; -import { useSelectedBlocks } from '../../hooks'; -import { filterAndFlattenAllowedBlocks } from '../../utils'; +import { useSelectedBlocks, useEditorCanvas } from '../../hooks'; +import { + filterAndFlattenAllowedBlocks, + getClientIdToBlockContentMapping, +} from '../../utils'; const { ClassifaiEditorSettingPanel } = window; +const apiUrl = `${ wpApiSettings.root }classifai/v1/rewrite-tone`; const allowedTextBlocks = [ 'core/paragraph', 'core/heading', @@ -12,11 +19,70 @@ const allowedTextBlocks = [ ]; const RewriteTonePlugin = () => { + // Holds a reference to the original, unmodified editor blocks. + const blocksBackup = useRef( null ); + + // Flag indicating if the previewer modal is open. + const [ isPopupVisible, setIsPopupVisible ] = useState( false ); + + // Flag indicating if a rewrite is in progress. + const [ isRewriteInProgress, setIsRewriteInProgress ] = useState( false ); + + // Stores all the editor blocks (modified and unmodified) that are created for preview. + const [ previewBlocks, setPreviewBlocks ] = useState( [] ); + + // Stores ChatGPT response. + const [ response, setResponse ] = useState( null ); + const allSelectedBlocks = useSelectedBlocks(); - const filteredBlocks = filterAndFlattenAllowedBlocks( allSelectedBlocks, allowedTextBlocks ); + + /** + * 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(); + + setIsPopupVisible( false ); + setIsRewriteInProgress( true ); + setPreviewBlocks( [] ); + + 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, + } ), + } ); + + setIsRewriteInProgress( false ); + + if ( ! __response.ok ) { + return; + } + + __response = await __response.json(); + setResponse( JSON.parse( __response ) ); + } catch ( e ) { + setIsRewriteInProgress( false ); + } + } return ( + ); }; From bb94e40086f71ae8eb348141545b903c5255ab82 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Fri, 28 Feb 2025 17:46:36 +0530 Subject: [PATCH 11/23] add replaceBlocksInSource util --- src/js/utils/index.js | 50 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/js/utils/index.js b/src/js/utils/index.js index c3c551fdf..ec115639c 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -13,6 +13,16 @@ export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) [] ); +/** + * Removes the delimiters from the content. + * + * @param {Array} blocks Array of { clientId, content } objects. + * @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. * @@ -21,7 +31,7 @@ export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) */ export const getClientIdToBlockContentMapping = ( blocks = [] ) => blocks.map( ( block ) => ( { clientId: block.clientId, - content: getBlockContent( block ), + content: removeBlockDelimiters( getBlockContent( block ) ), } ) ); /** @@ -30,7 +40,7 @@ export const getClientIdToBlockContentMapping = ( blocks = [] ) => blocks.map( ( * @param {string} htmlContent HTML as string. * @return {string} HTML string without outermost tags stripped. */ -export function stripOutermostTag( htmlContent = '' ) { +export const stripOutermostTag = ( htmlContent = '' ) => { // Parse the input HTML string into a DOM structure const parser = new DOMParser(); const doc = parser.parseFromString( htmlContent, 'text/html' ); @@ -41,3 +51,39 @@ export function stripOutermostTag( htmlContent = '' ) { // 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 ); +} From 30b24b9c58d4b1dd2ee3de01423f3c22b8f30a0c Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Fri, 28 Feb 2025 17:47:12 +0530 Subject: [PATCH 12/23] add InjectIframeStyles component --- .../{InjectIframeStyles.js => Inject-iframe-styles.js} | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename src/js/components/{InjectIframeStyles.js => Inject-iframe-styles.js} (95%) diff --git a/src/js/components/InjectIframeStyles.js b/src/js/components/Inject-iframe-styles.js similarity index 95% rename from src/js/components/InjectIframeStyles.js rename to src/js/components/Inject-iframe-styles.js index 9c7fe704d..597074eca 100644 --- a/src/js/components/InjectIframeStyles.js +++ b/src/js/components/Inject-iframe-styles.js @@ -1,4 +1,5 @@ -import { useEffect, createPortal } from '@wordpress/element'; +import { useEffect, useRef, createPortal, render } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import { useEditorCanvas } from '../hooks'; From 15511012f20a60dbc8eb2a5bffe26b9b4312db8c Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Fri, 28 Feb 2025 17:48:06 +0530 Subject: [PATCH 13/23] :white_check_mark: implement rewrite tone with preview and replacement --- src/js/components/index.js | 1 + src/js/features/rewrite-tone/index.js | 133 ++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/js/components/index.js b/src/js/components/index.js index 2dff88b8f..bec790042 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 index 55bce29cc..2594f3b12 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -1,12 +1,19 @@ import { registerPlugin } from '@wordpress/plugins'; -import { useRef, useState } from '@wordpress/element'; -import { store as blockEditorStore } from '@wordpress/block-editor'; +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 } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; -import { useSelectedBlocks, useEditorCanvas } from '../../hooks'; +import { useSelectedBlocks } from '../../hooks'; +import { InjectIframeStyles } from '../../components'; import { filterAndFlattenAllowedBlocks, getClientIdToBlockContentMapping, + stripOutermostTag, + replaceBlocksInSource, } from '../../utils'; const { ClassifaiEditorSettingPanel } = window; @@ -28,12 +35,18 @@ const RewriteTonePlugin = () => { // Flag indicating if a rewrite is in progress. const [ isRewriteInProgress, setIsRewriteInProgress ] = useState( false ); - // Stores all the editor blocks (modified and unmodified) that are created for preview. - const [ previewBlocks, setPreviewBlocks ] = useState( [] ); - // 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 ); + const allSelectedBlocks = useSelectedBlocks(); /** @@ -50,7 +63,7 @@ const RewriteTonePlugin = () => { setIsPopupVisible( false ); setIsRewriteInProgress( true ); - setPreviewBlocks( [] ); + setBlocksForPreview( [] ); const filteredBlocks = getClientIdToBlockContentMapping( filterAndFlattenAllowedBlocks( allSelectedBlocks, allowedTextBlocks ) @@ -80,10 +93,116 @@ const RewriteTonePlugin = () => { } } + /** + * Applies the result to the editor canvas when the user + * accepts it. + */ + const applyResult = () => { + modifiedBlocks.forEach( ( { clientId, blocks } ) => { + replaceBlock( clientId, blocks ); + } ); + + setIsPopupVisible( 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 ], + }; + } + + console.log( newBlock ) + + return { + clientId, + blocks: newBlock, + }; + } ); + + const __blocksForPreview = replaceBlocksInSource( + blocksBackup.current, + __modifiedBlocks + ); + + setBlocksForPreview( __blocksForPreview ); + setModifiedBlocks( __modifiedBlocks ); + setIsPopupVisible( true ); + }, [ response ] ); + return ( + <> + + { isPopupVisible && ( + setIsPopupVisible( false ) } + > + + + + +
      + + +
      +
      +
      + ) } + ); }; From 287675080df5000c1208f7126e347825f317d39d Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sun, 2 Mar 2025 23:53:15 +0530 Subject: [PATCH 14/23] add tones --- src/js/features/rewrite-tone/tones.js | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/js/features/rewrite-tone/tones.js diff --git a/src/js/features/rewrite-tone/tones.js b/src/js/features/rewrite-tone/tones.js new file mode 100644 index 000000000..c96c177e2 --- /dev/null +++ b/src/js/features/rewrite-tone/tones.js @@ -0,0 +1,86 @@ +import { __ } from '@wordpress/i18n'; + +export const tones = { + emotion: { + label: __( 'Emotion', 'classifai' ), + values: [ + { + key: 'happy', + label: __( 'Happy', 'classifai' ), + }, + { + key: 'neutral', + label: __( 'Neutral', 'classifai' ), + }, + { + key: 'sad', + label: __( 'Sad', 'classifai' ), + }, + ], + }, + formality: { + label: __( 'Formality', 'classifai' ), + values: [ + { + key: 'formal', + label: __( 'Formal', 'classifai' ), + description: __( 'Professional, structured, business-like.', 'classifai' ), + }, + { + key: 'informal', + label: __( 'Informal', 'classifai' ), + description: __( 'Conversational, relaxed.', 'classifai' ), + }, + { + key: 'supportive', + label: __( 'Supportive', 'classifai' ), + description: __( 'Reassuring, helpful.', 'classifai' ), + }, + ], + }, + intent: { + label: __( 'Intent', 'classifai' ), + value: [ + { + key: 'dramatic', + label: __( 'Dramatic', 'classifai' ), + description: __( 'Intense, theatrical.', 'classifai' ), + }, + { + key: 'persuasive', + label: __( 'Persuasive', 'classifai' ), + description: __( 'Convincing, compelling.', 'classifai' ), + }, + { + key: 'storytelling', + label: __( 'Storytelling', 'classifai' ), + description: __( 'Engaging, immersive.', 'classifai' ), + }, + ], + }, + audience: { + label: __( 'Audience', 'classifai' ), + value: [ + { + key: 'educational', + label: __( 'Educational', 'classifai' ), + description: __( 'Clear, instructive.', 'classifai' ), + }, + { + key: 'general', + label: __( 'General Audience', 'classifai' ), + description: __( 'Balanced, universally understandable.', 'classifai' ), + }, + { + key: 'maketing', + label: __( 'Marketing & Sales', 'classifai' ), + description: __( 'Promotional, action-driven.', 'classifai' ), + }, + { + key: 'professional', + label: __( 'Business & Professional', 'classifai' ), + description: __( 'Corporate, industry-focused.', 'classifai' ), + }, + ], + } +}; \ No newline at end of file From 2812643d7e228efda6f1126ac0ceed720f006255 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sun, 2 Mar 2025 23:54:11 +0530 Subject: [PATCH 15/23] move ClassifAI slot-fills to their separate files --- .../slot-fill/editor-header-plugin-area.js | 76 +++++++++++++++++++ .../slot-fill/editor-sidebar-plugin-area.js | 41 ++++++++++ src/js/features/slot-fill/index.js | 43 +---------- 3 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 src/js/features/slot-fill/editor-header-plugin-area.js create mode 100644 src/js/features/slot-fill/editor-sidebar-plugin-area.js 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..acb27dbd9 --- /dev/null +++ b/src/js/features/slot-fill/editor-header-plugin-area.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { + SlotFillProvider, + Slot, + Fill, + Button, + Popover, + +} from '@wordpress/components'; +import { PluginArea } from '@wordpress/plugins'; +import { createRoot, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * 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' ); + headerSettingsPanel.insertBefore( classifaiHeaderSettingButton, headerSettingsPanel.firstChild ); + + const wrapperRoot = createRoot( classifaiHeaderSettingButton ); + wrapperRoot.render( ); + +} ); + +function RenderClassifAIEditorHeaderPluginArea() { + const [ isOpen, setIsOpen ] = useState( false ); + + return ( + + { + isOpen && ( + setIsOpen( false ) } + onFocusOutside={ () => setIsOpen( false ) } + > +
      + + +
      +
      + ) + } + - - - { isPopupVisible && ( - setIsPopupVisible( false ) } - > - - - - -
      - - -
      -
      -
      - ) } + + + + + { isPreviewVisible && ( + setIsPreviewVisible( false ) } + > + + + + +
      + + +
      +
      +
      + ) } ); }; diff --git a/src/js/features/rewrite-tone/tones.js b/src/js/features/rewrite-tone/tones.js index c96c177e2..bb30de041 100644 --- a/src/js/features/rewrite-tone/tones.js +++ b/src/js/features/rewrite-tone/tones.js @@ -3,36 +3,36 @@ import { __ } from '@wordpress/i18n'; export const tones = { emotion: { label: __( 'Emotion', 'classifai' ), - values: [ + value: [ { - key: 'happy', + value: 'happy', label: __( 'Happy', 'classifai' ), }, { - key: 'neutral', + value: 'neutral', label: __( 'Neutral', 'classifai' ), }, { - key: 'sad', + value: 'sad', label: __( 'Sad', 'classifai' ), }, ], }, formality: { label: __( 'Formality', 'classifai' ), - values: [ + value: [ { - key: 'formal', + value: 'formal', label: __( 'Formal', 'classifai' ), description: __( 'Professional, structured, business-like.', 'classifai' ), }, { - key: 'informal', + value: 'informal', label: __( 'Informal', 'classifai' ), description: __( 'Conversational, relaxed.', 'classifai' ), }, { - key: 'supportive', + value: 'supportive', label: __( 'Supportive', 'classifai' ), description: __( 'Reassuring, helpful.', 'classifai' ), }, @@ -42,17 +42,17 @@ export const tones = { label: __( 'Intent', 'classifai' ), value: [ { - key: 'dramatic', + value: 'dramatic', label: __( 'Dramatic', 'classifai' ), description: __( 'Intense, theatrical.', 'classifai' ), }, { - key: 'persuasive', + value: 'persuasive', label: __( 'Persuasive', 'classifai' ), description: __( 'Convincing, compelling.', 'classifai' ), }, { - key: 'storytelling', + value: 'storytelling', label: __( 'Storytelling', 'classifai' ), description: __( 'Engaging, immersive.', 'classifai' ), }, @@ -62,22 +62,22 @@ export const tones = { label: __( 'Audience', 'classifai' ), value: [ { - key: 'educational', + value: 'educational', label: __( 'Educational', 'classifai' ), description: __( 'Clear, instructive.', 'classifai' ), }, { - key: 'general', + value: 'general', label: __( 'General Audience', 'classifai' ), description: __( 'Balanced, universally understandable.', 'classifai' ), }, { - key: 'maketing', + value: 'maketing', label: __( 'Marketing & Sales', 'classifai' ), description: __( 'Promotional, action-driven.', 'classifai' ), }, { - key: 'professional', + 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 index acb27dbd9..1f98e1781 100644 --- a/src/js/features/slot-fill/editor-header-plugin-area.js +++ b/src/js/features/slot-fill/editor-header-plugin-area.js @@ -55,8 +55,9 @@ function RenderClassifAIEditorHeaderPluginArea() { setIsOpen( false ) } onFocusOutside={ () => setIsOpen( false ) } + placement="top-end" > -
      +
      From b9f5405484b7548b1d63c5b579f336aabfc82ae1 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Mon, 3 Mar 2025 01:29:57 +0530 Subject: [PATCH 17/23] render tone descriptions --- src/js/features/rewrite-tone/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js index 993483508..2921932ad 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -219,6 +219,7 @@ const RewriteTonePlugin = () => { isBlock label={ tones.formality.label } value={ formality } + help={ tones.formality.value.find( el => el.value === formality )?.description } onChange={ ( newFormality ) => { setFormality( newFormality ); defaultToneAttribute( 'formality', newFormality ); @@ -231,6 +232,7 @@ const RewriteTonePlugin = () => { isBlock label={ tones.intent.label } value={ intent } + help={ tones.intent.value.find( el => el.value === intent )?.description } onChange={ ( newIntent ) => { setIntent( newIntent ); defaultToneAttribute( 'intent', newIntent ); @@ -243,6 +245,7 @@ const RewriteTonePlugin = () => { label={ tones.audience.label } options={ tones.audience.value } value={ audience } + help={ tones.audience.value.find( el => el.value === audience )?.description } onChange={ ( newAudience ) => { setAudience( newAudience ); defaultToneAttribute( 'audience', newAudience ); From 5ffc7c7b0bac530758e1a719421484b9884140ab Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Mon, 3 Mar 2025 02:06:19 +0530 Subject: [PATCH 18/23] add styling to the previewer controls --- src/js/features/rewrite-tone/index.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js index 2921932ad..8be5d2a3e 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -110,6 +110,10 @@ const RewriteTonePlugin = () => { body: JSON.stringify( { id: wp.data.select( editorStore ).getCurrentPostId(), content: filteredBlocks, + emotion, + formality, + intent, + audience, } ), } ); @@ -282,7 +286,14 @@ const RewriteTonePlugin = () => { > -
      +
      From ac412e15de5edd89f2c84f7b11a2d0d7053c1f44 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Mon, 3 Mar 2025 02:06:37 +0530 Subject: [PATCH 19/23] handle tone attributes server-side --- includes/Classifai/Features/RewriteTone.php | 14 ++++------ .../Classifai/Providers/OpenAI/ChatGPT.php | 26 ++++++++----------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/includes/Classifai/Features/RewriteTone.php b/includes/Classifai/Features/RewriteTone.php index 9cbc733a6..e9fb6b2eb 100644 --- a/includes/Classifai/Features/RewriteTone.php +++ b/includes/Classifai/Features/RewriteTone.php @@ -22,13 +22,6 @@ class RewriteTone extends Feature { */ const ID = 'feature_rewrite_tone'; - /** - * Prompt for rewriting tone. - * - * @var string - */ - public $prompt = 'You are modifying the tone and lingo of the following text to Renaissance English.'; - /** * Constructor. */ @@ -162,7 +155,11 @@ public function rest_endpoint_callback( WP_REST_Request $request ) { $request->get_param( 'id' ), 'rewrite_tone', [ - 'content' => $request->get_param( 'content' ), + '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' ), ] ) ); @@ -233,7 +230,6 @@ public function get_feature_default_settings(): array { 'rewrite_tone_prompt' => [ [ 'title' => esc_html__( 'ClassifAI default', 'classifai' ), - 'prompt' => $this->prompt, 'original' => 1, ], ], diff --git a/includes/Classifai/Providers/OpenAI/ChatGPT.php b/includes/Classifai/Providers/OpenAI/ChatGPT.php index a1fc4a821..3da0696d8 100644 --- a/includes/Classifai/Providers/OpenAI/ChatGPT.php +++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php @@ -227,6 +227,7 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '' break; case 'rewrite_tone': $return = $this->rewrite_tone( $post_id, $args ); + break; case 'key_takeaways': $return = $this->generate_key_takeaways( $post_id, $args ); break; @@ -883,21 +884,12 @@ 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() ); - $prompt = esc_textarea( get_default_prompt( $settings['rewrite_tone_prompt'] ) ?? $feature->prompt ); - /** - * Filter the prompt we will send to ChatGPT. - * - * @since x.x.x - * @hook classifai_chatgpt_rewrite_tone_prompt - * - * @param {string} $prompt Prompt we are sending to ChatGPT. Gets added before post content. - * @param {int} $post_id ID of post we are summarizing. - * @param {array} $args Arguments passed to endpoint. - * - * @return {string} Prompt. - */ - $prompt = apply_filters( 'classifai_chatgpt_rewrite_tone_prompt', $prompt, $post_id, $args ); + $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', @@ -910,7 +902,11 @@ public function rewrite_tone( int $post_id, array $args = [] ) { ], [ 'role' => 'system', - 'content' => "Please return each modified content with its corresponding 'clientId'.", + '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', From 465ceee3faa95960235adf20f465e387695f48af Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Mon, 3 Mar 2025 03:12:36 +0530 Subject: [PATCH 20/23] implement custom popover --- .../slot-fill/editor-header-plugin-area.js | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/js/features/slot-fill/editor-header-plugin-area.js b/src/js/features/slot-fill/editor-header-plugin-area.js index 1f98e1781..80a84f025 100644 --- a/src/js/features/slot-fill/editor-header-plugin-area.js +++ b/src/js/features/slot-fill/editor-header-plugin-area.js @@ -38,6 +38,7 @@ window._wpLoadBlockEditor.then( async () => { 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 ); @@ -48,22 +49,34 @@ window._wpLoadBlockEditor.then( async () => { 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 ( - { - isOpen && ( - setIsOpen( false ) } - onFocusOutside={ () => setIsOpen( false ) } - placement="top-end" - > -
      - - -
      -
      - ) - } + {/* ✋🏻🛑⛔️ 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. */} +
      + + +
      diff --git a/src/js/features/rewrite-tone/tones.js b/src/js/features/rewrite-tone/tones.js index bb30de041..93ef7d6ea 100644 --- a/src/js/features/rewrite-tone/tones.js +++ b/src/js/features/rewrite-tone/tones.js @@ -24,7 +24,10 @@ export const tones = { { value: 'formal', label: __( 'Formal', 'classifai' ), - description: __( 'Professional, structured, business-like.', 'classifai' ), + description: __( + 'Professional, structured, business-like.', + 'classifai' + ), }, { value: 'informal', @@ -69,7 +72,10 @@ export const tones = { { value: 'general', label: __( 'General Audience', 'classifai' ), - description: __( 'Balanced, universally understandable.', 'classifai' ), + description: __( + 'Balanced, universally understandable.', + 'classifai' + ), }, { value: 'maketing', @@ -82,5 +88,5 @@ export const tones = { description: __( 'Corporate, industry-focused.', 'classifai' ), }, ], - } -}; \ No newline at end of file + }, +}; diff --git a/src/js/features/slot-fill/editor-header-plugin-area.js b/src/js/features/slot-fill/editor-header-plugin-area.js index 80a84f025..13d642284 100644 --- a/src/js/features/slot-fill/editor-header-plugin-area.js +++ b/src/js/features/slot-fill/editor-header-plugin-area.js @@ -1,17 +1,9 @@ /** * External dependencies */ -import { - SlotFillProvider, - Slot, - Fill, - Button, - Popover, - -} from '@wordpress/components'; +import { SlotFillProvider, Slot, Fill, Button } from '@wordpress/components'; import { PluginArea } from '@wordpress/plugins'; import { createRoot, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -23,7 +15,9 @@ window._wpLoadBlockEditor.then( async () => { await new Promise( ( resolve ) => { const intervalId = setInterval( () => { - headerSettingsPanel = document.querySelector( '.editor-header__settings' ); + headerSettingsPanel = document.querySelector( + '.editor-header__settings' + ); if ( headerSettingsPanel ) { clearInterval( intervalId ); @@ -37,13 +31,17 @@ window._wpLoadBlockEditor.then( async () => { } const classifaiHeaderSettingButton = document.createElement( 'div' ); - classifaiHeaderSettingButton.classList.add( 'classifai-editor-header-setting-wrapper' ); + classifaiHeaderSettingButton.classList.add( + 'classifai-editor-header-setting-wrapper' + ); classifaiHeaderSettingButton.style.position = 'relative'; - headerSettingsPanel.insertBefore( classifaiHeaderSettingButton, headerSettingsPanel.firstChild ); + headerSettingsPanel.insertBefore( + classifaiHeaderSettingButton, + headerSettingsPanel.firstChild + ); const wrapperRoot = createRoot( classifaiHeaderSettingButton ); wrapperRoot.render( ); - } ); function RenderClassifAIEditorHeaderPluginArea() { @@ -53,12 +51,13 @@ function RenderClassifAIEditorHeaderPluginArea() { position: 'absolute', top: 'calc(100% + 4px)', right: 'calc(100% - 20px)', - boxShadow: '0 4px 5px #0000000a,0 12px 12px #00000008,0 16px 16px #00000005', + 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' - } + 'transform-origin': 'top right', + }; if ( ! isOpen ) { popoverStyles.opacity = 0; @@ -70,21 +69,23 @@ function RenderClassifAIEditorHeaderPluginArea() { return ( - {/* ✋🏻🛑⛔️ Don't use the Gutenberg's Popover component here. When clicked outside, it unmounts and breaks any ongoing + { /* ✋🏻🛑⛔️ 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. */} -
      + This is why we implemented a custom popover which hides in plan sight, instead of unmounting. */ } +
      -